summaryrefslogtreecommitdiff
path: root/spec
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-11-19 08:27:35 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-11-19 08:27:35 +0000
commit7e9c479f7de77702622631cff2628a9c8dcbc627 (patch)
treec8f718a08e110ad7e1894510980d2155a6549197 /spec
parente852b0ae16db4052c1c567d9efa4facc81146e88 (diff)
downloadgitlab-ce-7e9c479f7de77702622631cff2628a9c8dcbc627.tar.gz
Add latest changes from gitlab-org/gitlab@13-6-stable-eev13.6.0-rc42
Diffstat (limited to 'spec')
-rw-r--r--spec/bin/feature_flag_spec.rb29
-rw-r--r--spec/bin/sidekiq_cluster_spec.rb4
-rw-r--r--spec/controllers/admin/dashboard_controller_spec.rb12
-rw-r--r--spec/controllers/admin/users_controller_spec.rb53
-rw-r--r--spec/controllers/application_controller_spec.rb10
-rw-r--r--spec/controllers/autocomplete_controller_spec.rb11
-rw-r--r--spec/controllers/concerns/lfs_request_spec.rb75
-rw-r--r--spec/controllers/concerns/metrics_dashboard_spec.rb1
-rw-r--r--spec/controllers/concerns/send_file_upload_spec.rb55
-rw-r--r--spec/controllers/every_controller_spec.rb2
-rw-r--r--spec/controllers/groups/dependency_proxies_controller_spec.rb73
-rw-r--r--spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb161
-rw-r--r--spec/controllers/groups/registry/repositories_controller_spec.rb5
-rw-r--r--spec/controllers/groups/settings/integrations_controller_spec.rb6
-rw-r--r--spec/controllers/groups_controller_spec.rb86
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb36
-rw-r--r--spec/controllers/import/github_controller_spec.rb52
-rw-r--r--spec/controllers/invites_controller_spec.rb69
-rw-r--r--spec/controllers/jwks_controller_spec.rb36
-rw-r--r--spec/controllers/profiles_controller_spec.rb3
-rw-r--r--spec/controllers/projects/alerting/notifications_controller_spec.rb64
-rw-r--r--spec/controllers/projects/avatars_controller_spec.rb30
-rw-r--r--spec/controllers/projects/ci/lints_controller_spec.rb86
-rw-r--r--spec/controllers/projects/ci/pipeline_editor_controller_spec.rb53
-rw-r--r--spec/controllers/projects/cycle_analytics/events_controller_spec.rb2
-rw-r--r--spec/controllers/projects/cycle_analytics_controller_spec.rb2
-rw-r--r--spec/controllers/projects/imports_controller_spec.rb13
-rw-r--r--spec/controllers/projects/issues_controller_spec.rb6
-rw-r--r--spec/controllers/projects/merge_requests/diffs_controller_spec.rb26
-rw-r--r--spec/controllers/projects/merge_requests_controller_spec.rb79
-rw-r--r--spec/controllers/projects/notes_controller_spec.rb19
-rw-r--r--spec/controllers/projects/raw_controller_spec.rb31
-rw-r--r--spec/controllers/projects/registry/repositories_controller_spec.rb16
-rw-r--r--spec/controllers/projects/registry/tags_controller_spec.rb14
-rw-r--r--spec/controllers/projects/releases_controller_spec.rb97
-rw-r--r--spec/controllers/projects/repositories_controller_spec.rb4
-rw-r--r--spec/controllers/projects/settings/operations_controller_spec.rb13
-rw-r--r--spec/controllers/projects/settings/repository_controller_spec.rb10
-rw-r--r--spec/controllers/projects/snippets_controller_spec.rb10
-rw-r--r--spec/controllers/projects/static_site_editor_controller_spec.rb7
-rw-r--r--spec/controllers/projects/tags_controller_spec.rb72
-rw-r--r--spec/controllers/projects/templates_controller_spec.rb97
-rw-r--r--spec/controllers/projects/terraform_controller_spec.rb38
-rw-r--r--spec/controllers/projects_controller_spec.rb51
-rw-r--r--spec/controllers/registrations/welcome_controller_spec.rb79
-rw-r--r--spec/controllers/registrations_controller_spec.rb198
-rw-r--r--spec/controllers/repositories/lfs_storage_controller_spec.rb35
-rw-r--r--spec/controllers/search_controller_spec.rb5
-rw-r--r--spec/controllers/sessions_controller_spec.rb11
-rw-r--r--spec/controllers/snippets_controller_spec.rb8
-rw-r--r--spec/crystalball_env.rb25
-rw-r--r--spec/db/production/settings_spec.rb7
-rw-r--r--spec/db/schema_spec.rb16
-rw-r--r--spec/factories/alert_management/http_integrations.rb6
-rw-r--r--spec/factories/alerts_service_data.rb8
-rw-r--r--spec/factories/analytics/devops_adoption/segment_selections.rb18
-rw-r--r--spec/factories/analytics/devops_adoption/segments.rb7
-rw-r--r--spec/factories/analytics/instance_statistics/measurement.rb (renamed from spec/factories/instance_statistics/measurement.rb)0
-rw-r--r--spec/factories/audit_events.rb6
-rw-r--r--spec/factories/bulk_import.rb16
-rw-r--r--spec/factories/bulk_import/entities.rb20
-rw-r--r--spec/factories/bulk_import/trackers.rb10
-rw-r--r--spec/factories/ci/builds.rb23
-rw-r--r--spec/factories/ci/daily_build_group_report_results.rb8
-rw-r--r--spec/factories/ci/job_artifacts.rb20
-rw-r--r--spec/factories/ci/pipelines.rb8
-rw-r--r--spec/factories/ci/reports/test_case.rb41
-rw-r--r--spec/factories/ci/test_case.rb39
-rw-r--r--spec/factories/ci/test_case_failure.rb9
-rw-r--r--spec/factories/clusters/applications/helm.rb4
-rw-r--r--spec/factories/container_repositories.rb20
-rw-r--r--spec/factories/custom_emoji.rb3
-rw-r--r--spec/factories/dependency_proxy.rb9
-rw-r--r--spec/factories/design_management/design_at_version.rb6
-rw-r--r--spec/factories/design_management/designs.rb6
-rw-r--r--spec/factories/design_management/versions.rb4
-rw-r--r--spec/factories/import_configurations.rb10
-rw-r--r--spec/factories/issues/csv_import.rb8
-rw-r--r--spec/factories/merge_request_cleanup_schedules.rb8
-rw-r--r--spec/factories/packages.rb22
-rw-r--r--spec/factories/packages/build_info.rb11
-rw-r--r--spec/factories/packages/package_file.rb2
-rw-r--r--spec/factories/packages/package_file_build_infos.rb11
-rw-r--r--spec/factories/pages_deployments.rb10
-rw-r--r--spec/factories/project_hooks.rb2
-rw-r--r--spec/factories/project_statistics.rb1
-rw-r--r--spec/factories/projects.rb4
-rw-r--r--spec/factories/protected_branches/push_access_levels.rb1
-rw-r--r--spec/factories/resource_label_events.rb2
-rw-r--r--spec/factories/resource_milestone_event.rb4
-rw-r--r--spec/factories/resource_state_event.rb4
-rw-r--r--spec/factories/serverless/domain.rb4
-rw-r--r--spec/factories/serverless/domain_cluster.rb6
-rw-r--r--spec/factories/services.rb16
-rw-r--r--spec/factories/terraform/state.rb6
-rw-r--r--spec/factories/terraform/state_version.rb1
-rw-r--r--spec/factories/uploads.rb12
-rw-r--r--spec/factories/usage_data.rb5
-rw-r--r--spec/factories/users.rb2
-rw-r--r--spec/factories/wiki_pages.rb8
-rw-r--r--spec/factories/wikis.rb4
-rw-r--r--spec/fast_spec_helper.rb7
-rw-r--r--spec/features/admin/admin_builds_spec.rb6
-rw-r--r--spec/features/admin/admin_dev_ops_report_spec.rb70
-rw-r--r--spec/features/admin/admin_groups_spec.rb5
-rw-r--r--spec/features/admin/admin_settings_spec.rb30
-rw-r--r--spec/features/admin/admin_users_spec.rb68
-rw-r--r--spec/features/admin/admin_uses_repository_checks_spec.rb2
-rw-r--r--spec/features/alert_management_spec.rb15
-rw-r--r--spec/features/alerts_settings/user_views_alerts_settings_spec.rb64
-rw-r--r--spec/features/boards/add_issues_modal_spec.rb7
-rw-r--r--spec/features/breadcrumbs_schema_markup_spec.rb106
-rw-r--r--spec/features/calendar_spec.rb2
-rw-r--r--spec/features/callouts/registration_enabled_spec.rb49
-rw-r--r--spec/features/canonical_link_spec.rb70
-rw-r--r--spec/features/dashboard/datetime_on_tooltips_spec.rb2
-rw-r--r--spec/features/dashboard/shortcuts_spec.rb2
-rw-r--r--spec/features/discussion_comments/merge_request_spec.rb2
-rw-r--r--spec/features/expand_collapse_diffs_spec.rb2
-rw-r--r--spec/features/explore/user_explores_projects_spec.rb107
-rw-r--r--spec/features/file_uploads/multipart_invalid_uploads_spec.rb6
-rw-r--r--spec/features/frequently_visited_projects_and_groups_spec.rb47
-rw-r--r--spec/features/global_search_spec.rb8
-rw-r--r--spec/features/group_variables_spec.rb2
-rw-r--r--spec/features/groups/container_registry_spec.rb14
-rw-r--r--spec/features/groups/dependency_proxy_spec.rb111
-rw-r--r--spec/features/groups/members/filter_members_spec.rb37
-rw-r--r--spec/features/groups/members/leave_group_spec.rb10
-rw-r--r--spec/features/groups/members/list_members_spec.rb15
-rw-r--r--spec/features/groups/members/manage_groups_spec.rb39
-rw-r--r--spec/features/groups/members/manage_members_spec.rb26
-rw-r--r--spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb34
-rw-r--r--spec/features/groups/members/master_manages_access_requests_spec.rb4
-rw-r--r--spec/features/groups/members/search_members_spec.rb13
-rw-r--r--spec/features/groups/members/sort_members_spec.rb70
-rw-r--r--spec/features/groups/milestone_spec.rb1
-rw-r--r--spec/features/groups/navbar_spec.rb14
-rw-r--r--spec/features/groups/settings/repository_spec.rb17
-rw-r--r--spec/features/groups/show_spec.rb10
-rw-r--r--spec/features/groups_spec.rb48
-rw-r--r--spec/features/ide/user_sees_editor_info_spec.rb93
-rw-r--r--spec/features/incidents/user_views_incident_spec.rb82
-rw-r--r--spec/features/invites_spec.rb19
-rw-r--r--spec/features/issuables/close_reopen_report_toggle_spec.rb56
-rw-r--r--spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb4
-rw-r--r--spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb4
-rw-r--r--spec/features/issues/gfm_autocomplete_spec.rb43
-rw-r--r--spec/features/issues/user_creates_branch_and_merge_request_spec.rb4
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb27
-rw-r--r--spec/features/issues/user_interacts_with_awards_spec.rb4
-rw-r--r--spec/features/issues/user_sees_live_update_spec.rb2
-rw-r--r--spec/features/issues/user_views_issue_spec.rb2
-rw-r--r--spec/features/merge_request/user_comments_on_diff_spec.rb6
-rw-r--r--spec/features/merge_request/user_comments_on_merge_request_spec.rb21
-rw-r--r--spec/features/merge_request/user_expands_diff_spec.rb6
-rw-r--r--spec/features/merge_request/user_merges_immediately_spec.rb2
-rw-r--r--spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb13
-rw-r--r--spec/features/merge_request/user_resolves_conflicts_spec.rb8
-rw-r--r--spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb4
-rw-r--r--spec/features/merge_request/user_resolves_wip_mr_spec.rb4
-rw-r--r--spec/features/merge_request/user_sees_merge_widget_spec.rb5
-rw-r--r--spec/features/merge_request/user_sees_suggest_pipeline_spec.rb38
-rw-r--r--spec/features/merge_request/user_suggests_changes_on_diff_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_diffs_spec.rb2
-rw-r--r--spec/features/merge_requests/user_exports_as_csv_spec.rb31
-rw-r--r--spec/features/merge_requests/user_filters_by_draft_spec.rb31
-rw-r--r--spec/features/merge_requests/user_filters_by_target_branch_spec.rb10
-rw-r--r--spec/features/milestone_spec.rb4
-rw-r--r--spec/features/profile_spec.rb7
-rw-r--r--spec/features/profiles/account_spec.rb6
-rw-r--r--spec/features/profiles/personal_access_tokens_spec.rb8
-rw-r--r--spec/features/profiles/user_edit_profile_spec.rb77
-rw-r--r--spec/features/project_group_variables_spec.rb9
-rw-r--r--spec/features/project_variables_spec.rb27
-rw-r--r--spec/features/projects/blobs/edit_spec.rb12
-rw-r--r--spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb2
-rw-r--r--spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb1
-rw-r--r--spec/features/projects/ci/editor_spec.rb21
-rw-r--r--spec/features/projects/ci/lint_spec.rb12
-rw-r--r--spec/features/projects/container_registry_spec.rb18
-rw-r--r--spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb4
-rw-r--r--spec/features/projects/files/user_browses_files_spec.rb49
-rw-r--r--spec/features/projects/issues/design_management/user_uploads_designs_spec.rb2
-rw-r--r--spec/features/projects/jobs_spec.rb29
-rw-r--r--spec/features/projects/navbar_spec.rb19
-rw-r--r--spec/features/projects/pages_spec.rb2
-rw-r--r--spec/features/projects/pipeline_schedules_spec.rb2
-rw-r--r--spec/features/projects/pipelines/pipeline_spec.rb5
-rw-r--r--spec/features/projects/pipelines/pipelines_spec.rb1
-rw-r--r--spec/features/projects/releases/user_views_edit_release_spec.rb2
-rw-r--r--spec/features/projects/releases/user_views_releases_spec.rb102
-rw-r--r--spec/features/projects/settings/registry_settings_spec.rb12
-rw-r--r--spec/features/projects/settings/service_desk_setting_spec.rb2
-rw-r--r--spec/features/projects/settings/webhooks_settings_spec.rb1
-rw-r--r--spec/features/projects/show/schema_markup_spec.rb23
-rw-r--r--spec/features/projects/snippets/create_snippet_spec.rb2
-rw-r--r--spec/features/projects/terraform_spec.rb48
-rw-r--r--spec/features/projects_spec.rb2
-rw-r--r--spec/features/read_only_spec.rb20
-rw-r--r--spec/features/runners_spec.rb13
-rw-r--r--spec/features/search/user_searches_for_code_spec.rb5
-rw-r--r--spec/features/search/user_searches_for_issues_spec.rb25
-rw-r--r--spec/features/search/user_searches_for_merge_requests_spec.rb5
-rw-r--r--spec/features/search/user_searches_for_milestones_spec.rb5
-rw-r--r--spec/features/search/user_searches_for_wiki_pages_spec.rb5
-rw-r--r--spec/features/search/user_uses_header_search_field_spec.rb56
-rw-r--r--spec/features/search/user_uses_search_filters_spec.rb21
-rw-r--r--spec/features/static_site_editor_spec.rb40
-rw-r--r--spec/features/uploads/user_uploads_file_to_note_spec.rb6
-rw-r--r--spec/features/users/login_spec.rb74
-rw-r--r--spec/features/users/show_spec.rb68
-rw-r--r--spec/features/users/signup_spec.rb117
-rw-r--r--spec/finders/alert_management/http_integrations_finder_spec.rb70
-rw-r--r--spec/finders/ci/commit_statuses_finder_spec.rb178
-rw-r--r--spec/finders/ci/jobs_finder_spec.rb141
-rw-r--r--spec/finders/environment_names_finder_spec.rb180
-rw-r--r--spec/finders/feature_flags_user_lists_finder_spec.rb31
-rw-r--r--spec/finders/group_descendants_finder_spec.rb37
-rw-r--r--spec/finders/issues_finder_spec.rb44
-rw-r--r--spec/finders/merge_requests/by_approvals_finder_spec.rb1
-rw-r--r--spec/finders/packages/group_packages_finder_spec.rb146
-rw-r--r--spec/finders/packages/npm/package_finder_spec.rb6
-rw-r--r--spec/finders/personal_access_tokens_finder_spec.rb21
-rw-r--r--spec/finders/security/jobs_finder_spec.rb21
-rw-r--r--spec/finders/security/license_compliance_jobs_finder_spec.rb24
-rw-r--r--spec/finders/security/security_jobs_finder_spec.rb47
-rw-r--r--spec/finders/user_groups_counter_spec.rb45
-rw-r--r--spec/fixtures/api/schemas/entities/test_case.json8
-rw-r--r--spec/fixtures/api/schemas/entities/test_case/recent_failures.json12
-rw-r--r--spec/fixtures/api/schemas/environment.json1
-rw-r--r--spec/fixtures/api/schemas/graphql/container_repositories.json12
-rw-r--r--spec/fixtures/api/schemas/graphql/container_repository.json40
-rw-r--r--spec/fixtures/api/schemas/graphql/container_repository_details.json49
-rw-r--r--spec/fixtures/api/schemas/internal/pages/lookup_path.json8
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/package_files.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/packages/package_with_build.json5
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/release.json6
-rw-r--r--spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json6
-rw-r--r--spec/fixtures/csv_no_headers.csv3
-rw-r--r--spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gzbin0 -> 32 bytes
-rw-r--r--spec/fixtures/domain_denylist.txt (renamed from spec/fixtures/domain_blacklist.txt)0
-rw-r--r--spec/fixtures/junit/junit_with_duplicate_failed_test_names.xml.gzbin0 -> 576 bytes
-rw-r--r--spec/fixtures/junit/junit_with_three_failures.xml.gzbin0 -> 545 bytes
-rw-r--r--spec/fixtures/lib/gitlab/import_export/complex/project.json20
-rw-r--r--spec/fixtures/lib/gitlab/import_export/designs/project.json9
-rw-r--r--spec/fixtures/packages/debian/README.md21
-rw-r--r--spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb1
-rw-r--r--spec/fixtures/packages/debian/libsample0_1.2.3~alpha2_amd64.debbin0 -> 1124 bytes
-rw-r--r--spec/fixtures/packages/debian/sample-dev_1.2.3~binary_amd64.debbin0 -> 1164 bytes
-rw-r--r--spec/fixtures/packages/debian/sample-udeb_1.2.3~alpha2_amd64.udebbin0 -> 736 bytes
-rw-r--r--spec/fixtures/packages/debian/sample/debian/.gitignore8
-rw-r--r--spec/fixtures/packages/debian/sample/debian/changelog5
-rw-r--r--spec/fixtures/packages/debian/sample/debian/control35
-rwxr-xr-xspec/fixtures/packages/debian/sample/debian/rules6
-rw-r--r--spec/fixtures/packages/debian/sample/debian/source/format1
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc19
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xzbin0 -> 864 bytes
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo180
-rw-r--r--spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes39
-rw-r--r--spec/fixtures/whats_new/20201225_01_01.yml (renamed from spec/fixtures/whats_new/01.yml)0
-rw-r--r--spec/fixtures/whats_new/20201225_01_02.yml (renamed from spec/fixtures/whats_new/02.yml)0
-rw-r--r--spec/fixtures/whats_new/20201225_01_05.yml (renamed from spec/fixtures/whats_new/05.yml)1
-rw-r--r--spec/frontend/__mocks__/@gitlab/ui.js12
-rw-r--r--spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap24
-rw-r--r--spec/frontend/access_tokens/components/expires_at_field_spec.js34
-rw-r--r--spec/frontend/alert_management/components/alert_details_spec.js45
-rw-r--r--spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap50
-rw-r--r--spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap97
-rw-r--r--spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap47
-rw-r--r--spec/frontend/alerts_settings/alert_mapping_builder_spec.js97
-rw-r--r--spec/frontend/alerts_settings/alerts_integrations_list_spec.js (renamed from spec/frontend/alert_settings/alerts_integrations_list_spec.js)56
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_form_new_spec.js364
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_form_old_spec.js (renamed from spec/frontend/alert_settings/alert_settings_form_spec.js)51
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js415
-rw-r--r--spec/frontend/alerts_settings/mocks/apollo_mock.js123
-rw-r--r--spec/frontend/alerts_settings/mocks/integrations.json38
-rw-r--r--spec/frontend/alerts_settings/util.js30
-rw-r--r--spec/frontend/analytics/instance_statistics/apollo_mock_data.js62
-rw-r--r--spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap41
-rw-r--r--spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap161
-rw-r--r--spec/frontend/analytics/instance_statistics/components/app_spec.js17
-rw-r--r--spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js177
-rw-r--r--spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js189
-rw-r--r--spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js216
-rw-r--r--spec/frontend/analytics/instance_statistics/components/users_chart_spec.js45
-rw-r--r--spec/frontend/analytics/instance_statistics/mock_data.js7
-rw-r--r--spec/frontend/analytics/instance_statistics/utils_spec.js55
-rw-r--r--spec/frontend/api_spec.js196
-rw-r--r--spec/frontend/awards_handler_spec.js23
-rw-r--r--spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js94
-rw-r--r--spec/frontend/blob/components/blob_header_default_actions_spec.js5
-rw-r--r--spec/frontend/blob/components/blob_header_filepath_spec.js2
-rw-r--r--spec/frontend/blob/components/blob_header_spec.js4
-rw-r--r--spec/frontend/blob/pipeline_tour_success_mock_data.js2
-rw-r--r--spec/frontend/blob/pipeline_tour_success_modal_spec.js2
-rw-r--r--spec/frontend/boards/board_list_new_spec.js2
-rw-r--r--spec/frontend/boards/components/board_assignee_dropdown_spec.js308
-rw-r--r--spec/frontend/boards/components/board_card_spec.js4
-rw-r--r--spec/frontend/boards/components/board_column_new_spec.js72
-rw-r--r--spec/frontend/boards/components/board_column_spec.js2
-rw-r--r--spec/frontend/boards/components/board_list_header_new_spec.js169
-rw-r--r--spec/frontend/boards/components/board_new_issue_new_spec.js115
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js137
-rw-r--r--spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js157
-rw-r--r--spec/frontend/boards/mock_data.js30
-rw-r--r--spec/frontend/boards/stores/actions_spec.js323
-rw-r--r--spec/frontend/boards/stores/getters_spec.js30
-rw-r--r--spec/frontend/boards/stores/mutations_spec.js114
-rw-r--r--spec/frontend/ci_lint/components/ci_lint_results_spec.js35
-rw-r--r--spec/frontend/ci_lint/components/ci_lint_spec.js42
-rw-r--r--spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap73
-rw-r--r--spec/frontend/ci_lint/graphql/resolvers_spec.js38
-rw-r--r--spec/frontend/ci_lint/mock_data.js45
-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.js120
-rw-r--r--spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap2
-rw-r--r--spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap2
-rw-r--r--spec/frontend/clusters/components/applications_spec.js2
-rw-r--r--spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap2
-rw-r--r--spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js112
-rw-r--r--spec/frontend/create_cluster/eks_cluster/services/aws_services_facade_spec.js26
-rw-r--r--spec/frontend/create_cluster/eks_cluster/store/actions_spec.js61
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js3
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js2
-rw-r--r--spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js3
-rw-r--r--spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js8
-rw-r--r--spec/frontend/deploy_freeze/helpers.js9
-rw-r--r--spec/frontend/deploy_freeze/store/actions_spec.js3
-rw-r--r--spec/frontend/deploy_freeze/store/mutations_spec.js8
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js10
-rw-r--r--spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap115
-rw-r--r--spec/frontend/design_management/components/design_overlay_spec.js18
-rw-r--r--spec/frontend/design_management/components/design_scaler_spec.js104
-rw-r--r--spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap2
-rw-r--r--spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap39
-rw-r--r--spec/frontend/design_management/components/upload/button_spec.js15
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js23
-rw-r--r--spec/frontend/design_management/mock_data/discussion.js2
-rw-r--r--spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap268
-rw-r--r--spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap2
-rw-r--r--spec/frontend/design_management/pages/design/index_spec.js116
-rw-r--r--spec/frontend/design_management/pages/index_spec.js46
-rw-r--r--spec/frontend/design_management/utils/cache_update_spec.js16
-rw-r--r--spec/frontend/design_management/utils/design_management_utils_spec.js10
-rw-r--r--spec/frontend/diffs/components/app_spec.js14
-rw-r--r--spec/frontend/diffs/components/collapsed_files_warning_spec.js9
-rw-r--r--spec/frontend/diffs/components/diff_comment_cell_spec.js43
-rw-r--r--spec/frontend/diffs/components/diff_content_spec.js22
-rw-r--r--spec/frontend/diffs/components/diff_file_header_spec.js34
-rw-r--r--spec/frontend/diffs/components/diff_file_spec.js530
-rw-r--r--spec/frontend/diffs/components/diff_row_spec.js127
-rw-r--r--spec/frontend/diffs/components/diff_row_utils_spec.js73
-rw-r--r--spec/frontend/diffs/components/diff_view_spec.js82
-rw-r--r--spec/frontend/diffs/components/inline_diff_expansion_row_spec.js32
-rw-r--r--spec/frontend/diffs/components/inline_diff_table_row_spec.js26
-rw-r--r--spec/frontend/diffs/components/inline_diff_view_spec.js61
-rw-r--r--spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js31
-rw-r--r--spec/frontend/diffs/components/parallel_diff_table_row_spec.js28
-rw-r--r--spec/frontend/diffs/components/tree_list_spec.js6
-rw-r--r--spec/frontend/diffs/mock_data/diff_file.js1
-rw-r--r--spec/frontend/diffs/mock_data/diff_file_unreadable.js1
-rw-r--r--spec/frontend/diffs/store/actions_spec.js37
-rw-r--r--spec/frontend/diffs/store/getters_spec.js56
-rw-r--r--spec/frontend/diffs/store/mutations_spec.js15
-rw-r--r--spec/frontend/diffs/store/utils_spec.js21
-rw-r--r--spec/frontend/editor/editor_lite_spec.js2
-rw-r--r--spec/frontend/environments/environment_delete_spec.js8
-rw-r--r--spec/frontend/error_tracking/components/error_tracking_list_spec.js28
-rw-r--r--spec/frontend/error_tracking_settings/components/project_dropdown_spec.js14
-rw-r--r--spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js8
-rw-r--r--spec/frontend/feature_flags/components/form_spec.js10
-rw-r--r--spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js2
-rw-r--r--spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js78
-rw-r--r--spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js2
-rw-r--r--spec/frontend/feature_flags/components/strategy_parameters_spec.js12
-rw-r--r--spec/frontend/feature_flags/components/strategy_spec.js17
-rw-r--r--spec/frontend/feature_flags/mock_data.js2
-rw-r--r--spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js60
-rw-r--r--spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js69
-rw-r--r--spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js50
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js3
-rw-r--r--spec/frontend/fixtures/freeze_period.rb9
-rw-r--r--spec/frontend/fixtures/groups.rb9
-rw-r--r--spec/frontend/fixtures/issues.rb2
-rw-r--r--spec/frontend/fixtures/projects.rb24
-rw-r--r--spec/frontend/fixtures/search.rb43
-rw-r--r--spec/frontend/fixtures/static/signin_tabs.html3
-rw-r--r--spec/frontend/gfm_auto_complete_spec.js235
-rw-r--r--spec/frontend/graphql_shared/utils_spec.js4
-rw-r--r--spec/frontend/groups/components/app_spec.js124
-rw-r--r--spec/frontend/groups/components/item_actions_spec.js89
-rw-r--r--spec/frontend/groups/members/index_spec.js13
-rw-r--r--spec/frontend/groups/mock_data.js5
-rw-r--r--spec/frontend/helpers/fake_date.js2
-rw-r--r--spec/frontend/helpers/fake_date_spec.js12
-rw-r--r--spec/frontend/helpers/mock_apollo_helper.js4
-rw-r--r--spec/frontend/helpers/startup_css_helper_spec.js20
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js75
-rw-r--r--spec/frontend/ide/components/commit_sidebar/list_spec.js1
-rw-r--r--spec/frontend/ide/components/ide_side_bar_spec.js44
-rw-r--r--spec/frontend/ide/components/ide_spec.js214
-rw-r--r--spec/frontend/ide/components/ide_status_list_spec.js27
-rw-r--r--spec/frontend/ide/components/pipelines/list_spec.js7
-rw-r--r--spec/frontend/ide/components/repo_editor_spec.js7
-rw-r--r--spec/frontend/ide/components/repo_tab_spec.js16
-rw-r--r--spec/frontend/ide/components/terminal/empty_state_spec.js21
-rw-r--r--spec/frontend/ide/helpers.js15
-rw-r--r--spec/frontend/ide/stores/actions/file_spec.js14
-rw-r--r--spec/frontend/ide/stores/actions_spec.js12
-rw-r--r--spec/frontend/ide/stores/modules/editor/actions_spec.js36
-rw-r--r--spec/frontend/ide/stores/modules/editor/getters_spec.js31
-rw-r--r--spec/frontend/ide/stores/modules/editor/mutations_spec.js78
-rw-r--r--spec/frontend/ide/stores/modules/editor/setup_spec.js44
-rw-r--r--spec/frontend/ide/stores/mutations/file_spec.js12
-rw-r--r--spec/frontend/import_projects/components/import_projects_table_spec.js2
-rw-r--r--spec/frontend/import_projects/store/actions_spec.js51
-rw-r--r--spec/frontend/incidents/components/incidents_list_spec.js10
-rw-r--r--spec/frontend/integrations/edit/components/confirmation_modal_spec.js2
-rw-r--r--spec/frontend/integrations/edit/components/integration_form_spec.js74
-rw-r--r--spec/frontend/integrations/edit/store/actions_spec.js27
-rw-r--r--spec/frontend/integrations/edit/store/getters_spec.js32
-rw-r--r--spec/frontend/integrations/edit/store/mutations_spec.js24
-rw-r--r--spec/frontend/integrations/edit/store/state_spec.js1
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js87
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js112
-rw-r--r--spec/frontend/issuable/related_issues/components/issue_token_spec.js4
-rw-r--r--spec/frontend/issuable/related_issues/components/related_issues_block_spec.js4
-rw-r--r--spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js97
-rw-r--r--spec/frontend/issuable_list/components/issuable_item_spec.js235
-rw-r--r--spec/frontend/issuable_list/components/issuable_list_root_spec.js198
-rw-r--r--spec/frontend/issuable_list/mock_data.js2
-rw-r--r--spec/frontend/issue_show/components/app_spec.js50
-rw-r--r--spec/frontend/issue_show/components/header_actions_spec.js328
-rw-r--r--spec/frontend/issue_show/issue_spec.js5
-rw-r--r--spec/frontend/issues_list/components/issuable_spec.js6
-rw-r--r--spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap6
-rw-r--r--spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js76
-rw-r--r--spec/frontend/jobs/components/job_sidebar_details_container_spec.js132
-rw-r--r--spec/frontend/jobs/components/job_sidebar_retry_button_spec.js70
-rw-r--r--spec/frontend/jobs/components/log/line_spec.js149
-rw-r--r--spec/frontend/jobs/components/sidebar_spec.js201
-rw-r--r--spec/frontend/lib/utils/apollo_startup_js_link_spec.js375
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js19
-rw-r--r--spec/frontend/lib/utils/datetime_utility_spec.js9
-rw-r--r--spec/frontend/lib/utils/dom_utils_spec.js33
-rw-r--r--spec/frontend/lib/utils/number_utility_spec.js11
-rw-r--r--spec/frontend/lib/utils/text_utility_spec.js15
-rw-r--r--spec/frontend/milestones/milestone_combobox_spec.js518
-rw-r--r--spec/frontend/milestones/mock_data.js94
-rw-r--r--spec/frontend/milestones/project_milestone_combobox_spec.js186
-rw-r--r--spec/frontend/milestones/stores/actions_spec.js173
-rw-r--r--spec/frontend/milestones/stores/getter_spec.js18
-rw-r--r--spec/frontend/milestones/stores/mutations_spec.js101
-rw-r--r--spec/frontend/monitoring/components/charts/column_spec.js3
-rw-r--r--spec/frontend/monitoring/components/charts/stacked_column_spec.js12
-rw-r--r--spec/frontend/monitoring/components/charts/time_series_spec.js6
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js2
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js2
-rw-r--r--spec/frontend/monitoring/components/embeds/embed_group_spec.js4
-rw-r--r--spec/frontend/monitoring/components/variables/dropdown_field_spec.js6
-rw-r--r--spec/frontend/notes/components/discussion_actions_spec.js6
-rw-r--r--spec/frontend/notes/components/discussion_filter_note_spec.js21
-rw-r--r--spec/frontend/notes/components/note_actions_spec.js20
-rw-r--r--spec/frontend/notes/components/note_awards_list_spec.js5
-rw-r--r--spec/frontend/notes/components/note_form_spec.js22
-rw-r--r--spec/frontend/notes/components/note_header_spec.js16
-rw-r--r--spec/frontend/notes/mock_data.js5
-rw-r--r--spec/frontend/packages/details/components/package_title_spec.js10
-rw-r--r--spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap2
-rw-r--r--spec/frontend/pages/labels/components/promote_label_modal_spec.js7
-rw-r--r--spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js105
-rw-r--r--spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js6
-rw-r--r--spec/frontend/performance_bar/components/detailed_metric_spec.js45
-rw-r--r--spec/frontend/pipeline_editor/components/text_editor_spec.js41
-rw-r--r--spec/frontend/pipeline_editor/graphql/resolvers_spec.js42
-rw-r--r--spec/frontend/pipeline_editor/mock_data.js10
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js139
-rw-r--r--spec/frontend/pipeline_new/components/pipeline_new_form_spec.js22
-rw-r--r--spec/frontend/pipelines/graph/graph_component_spec.js4
-rw-r--r--spec/frontend/pipelines/graph/linked_pipeline_spec.js8
-rw-r--r--spec/frontend/pipelines/graph/linked_pipelines_column_spec.js2
-rw-r--r--spec/frontend/pipelines/header_component_spec.js73
-rw-r--r--spec/frontend/pipelines/pipeline_graph/mock_data.js15
-rw-r--r--spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js60
-rw-r--r--spec/frontend/pipelines/pipelines_spec.js2
-rw-r--r--spec/frontend/pipelines/test_reports/test_case_details_spec.js74
-rw-r--r--spec/frontend/pipelines/test_reports/test_suite_table_spec.js3
-rw-r--r--spec/frontend/popovers/components/popovers_spec.js129
-rw-r--r--spec/frontend/popovers/index_spec.js104
-rw-r--r--spec/frontend/profile/account/components/update_username_spec.js208
-rw-r--r--spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap67
-rw-r--r--spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap51
-rw-r--r--spec/frontend/profile/preferences/components/integration_view_spec.js124
-rw-r--r--spec/frontend/profile/preferences/components/profile_preferences_spec.js57
-rw-r--r--spec/frontend/profile/preferences/mock_data.js18
-rw-r--r--spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap2
-rw-r--r--spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap12
-rw-r--r--spec/frontend/projects/pipelines/charts/components/app_spec.js2
-rw-r--r--spec/frontend/projects/pipelines/charts/mock_data.js1
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js76
-rw-r--r--spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js44
-rw-r--r--spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js18
-rw-r--r--spec/frontend/prometheus_metrics/prometheus_metrics_spec.js3
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/details_page/tags_list_spec.js24
-rw-r--r--spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js9
-rw-r--r--spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js11
-rw-r--r--spec/frontend/registry/explorer/mock_data.js11
-rw-r--r--spec/frontend/registry/explorer/pages/details_spec.js41
-rw-r--r--spec/frontend/registry/explorer/stores/actions_spec.js79
-rw-r--r--spec/frontend/registry/explorer/stores/mutations_spec.js9
-rw-r--r--spec/frontend/registry/explorer/utils_spec.js45
-rw-r--r--spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap2
-rw-r--r--spec/frontend/releases/__snapshots__/util_spec.js.snap30
-rw-r--r--spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap9
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js3
-rw-r--r--spec/frontend/releases/components/issuable_stats_spec.js114
-rw-r--r--spec/frontend/releases/components/release_block_milestone_info_spec.js77
-rw-r--r--spec/frontend/releases/components/releases_sort_spec.js66
-rw-r--r--spec/frontend/releases/components/tag_field_exsting_spec.js21
-rw-r--r--spec/frontend/releases/stores/modules/detail/actions_spec.js1
-rw-r--r--spec/frontend/releases/stores/modules/detail/mutations_spec.js1
-rw-r--r--spec/frontend/releases/stores/modules/list/actions_spec.js48
-rw-r--r--spec/frontend/releases/stores/modules/list/mutations_spec.js12
-rw-r--r--spec/frontend/releases/util_spec.js14
-rw-r--r--spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js57
-rw-r--r--spec/frontend/reports/components/grouped_test_reports_app_spec.js110
-rw-r--r--spec/frontend/reports/components/report_section_spec.js2
-rw-r--r--spec/frontend/reports/mock_data/recent_failures_report.json46
-rw-r--r--spec/frontend/reports/store/mutations_spec.js2
-rw-r--r--spec/frontend/reports/store/utils_spec.js48
-rw-r--r--spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap2
-rw-r--r--spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap1
-rw-r--r--spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js196
-rw-r--r--spec/frontend/search/dropdown_filter/mock_data.js5
-rw-r--r--spec/frontend/search/group_filter/components/group_filter_spec.js172
-rw-r--r--spec/frontend/search/highlight_blob_search_result_spec.js (renamed from spec/frontend/pages/search/show/highlight_blob_search_result_spec.js)6
-rw-r--r--spec/frontend/search/index_spec.js47
-rw-r--r--spec/frontend/search/mock_data.js24
-rw-r--r--spec/frontend/search/sidebar/components/app_spec.js103
-rw-r--r--spec/frontend/search/sidebar/components/confidentiality_filter_spec.js65
-rw-r--r--spec/frontend/search/sidebar/components/radio_filter_spec.js111
-rw-r--r--spec/frontend/search/sidebar/components/status_filter_spec.js65
-rw-r--r--spec/frontend/search/store/actions_spec.js90
-rw-r--r--spec/frontend/search/store/mutations_spec.js48
-rw-r--r--spec/frontend/search_spec.js14
-rw-r--r--spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js257
-rw-r--r--spec/frontend/set_status_modal/user_availability_status_spec.js31
-rw-r--r--spec/frontend/sidebar/__snapshots__/todo_spec.js.snap2
-rw-r--r--spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js357
-rw-r--r--spec/frontend/sidebar/issuable_assignees_spec.js18
-rw-r--r--spec/frontend/sidebar/sidebar_labels_spec.js171
-rw-r--r--spec/frontend/sidebar/subscriptions_spec.js2
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap10
-rw-r--r--spec/frontend/snippets/components/edit_spec.js85
-rw-r--r--spec/frontend/snippets/components/snippet_visibility_edit_spec.js25
-rw-r--r--spec/frontend/static_site_editor/components/edit_area_spec.js10
-rw-r--r--spec/frontend/static_site_editor/components/edit_meta_controls_spec.js40
-rw-r--r--spec/frontend/static_site_editor/components/edit_meta_modal_spec.js127
-rw-r--r--spec/frontend/static_site_editor/mock_data.js22
-rw-r--r--spec/frontend/static_site_editor/pages/home_spec.js11
-rw-r--r--spec/frontend/static_site_editor/services/front_matterify_spec.js8
-rw-r--r--spec/frontend/static_site_editor/services/renderers/render_image_spec.js96
-rw-r--r--spec/frontend/terraform/components/empty_state_spec.js26
-rw-r--r--spec/frontend/terraform/components/states_table_spec.js102
-rw-r--r--spec/frontend/terraform/components/terraform_list_spec.js172
-rw-r--r--spec/frontend/tooltips/components/tooltips_spec.js8
-rw-r--r--spec/frontend/tooltips/index_spec.js29
-rw-r--r--spec/frontend/tracking_spec.js1
-rw-r--r--spec/frontend/vue_mr_widget/components/extensions/index_spec.js31
-rw-r--r--spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js35
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js4
-rw-r--r--spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js2
-rw-r--r--spec/frontend/vue_mr_widget/mr_widget_options_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap32
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap24
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap27
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/awards_list_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/__snapshots__/simple_viewer_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js20
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/file_row_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js33
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js1
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/integration_help_text_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js12
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js1
-rw-r--r--spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js166
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js101
-rw-r--r--spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/members/utils_spec.js97
-rw-r--r--spec/frontend/vue_shared/components/modal_copy_button_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/multiselect_dropdown_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js21
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js26
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/mock_data.js107
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js119
-rw-r--r--spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js375
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/stacked_progress_bar_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap (renamed from spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap)227
-rw-r--r--spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js (renamed from spec/frontend/design_management/components/upload/design_dropzone_spec.js)45
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js30
-rw-r--r--spec/frontend/vue_shared/directives/validation_spec.js132
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js52
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js203
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js84
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js203
-rw-r--r--spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js84
-rw-r--r--spec/frontend/vuex_shared/modules/members/actions_spec.js134
-rw-r--r--spec/frontend/vuex_shared/modules/members/mutations_spec.js67
-rw-r--r--spec/frontend/whats_new/components/app_spec.js67
-rw-r--r--spec/frontend/whats_new/store/actions_spec.js21
-rw-r--r--spec/frontend/whats_new/store/mutations_spec.js35
-rw-r--r--spec/frontend/whats_new/utils/get_drawer_body_height_spec.js38
-rw-r--r--spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap22
-rw-r--r--spec/frontend_integration/test_helpers/fixtures.js2
-rw-r--r--spec/graphql/mutations/alert_management/http_integration/create_spec.rb57
-rw-r--r--spec/graphql/mutations/alert_management/http_integration/destroy_spec.rb58
-rw-r--r--spec/graphql/mutations/alert_management/http_integration/reset_token_spec.rb58
-rw-r--r--spec/graphql/mutations/alert_management/http_integration/update_spec.rb58
-rw-r--r--spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb72
-rw-r--r--spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb58
-rw-r--r--spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb58
-rw-r--r--spec/graphql/mutations/alert_management/update_alert_status_spec.rb4
-rw-r--r--spec/graphql/mutations/commits/create_spec.rb29
-rw-r--r--spec/graphql/mutations/container_expiration_policies/update_spec.rb20
-rw-r--r--spec/graphql/mutations/container_repositories/destroy_spec.rb67
-rw-r--r--spec/graphql/mutations/issues/set_assignees_spec.rb14
-rw-r--r--spec/graphql/mutations/issues/set_confidential_spec.rb4
-rw-r--r--spec/graphql/mutations/issues/set_due_date_spec.rb4
-rw-r--r--spec/graphql/mutations/issues/set_locked_spec.rb4
-rw-r--r--spec/graphql/mutations/issues/set_severity_spec.rb6
-rw-r--r--spec/graphql/mutations/issues/update_spec.rb6
-rw-r--r--spec/graphql/mutations/labels/create_spec.rb80
-rw-r--r--spec/graphql/mutations/merge_requests/set_assignees_spec.rb14
-rw-r--r--spec/graphql/mutations/merge_requests/set_labels_spec.rb4
-rw-r--r--spec/graphql/mutations/merge_requests/set_locked_spec.rb4
-rw-r--r--spec/graphql/mutations/merge_requests/set_milestone_spec.rb2
-rw-r--r--spec/graphql/mutations/merge_requests/set_wip_spec.rb4
-rw-r--r--spec/graphql/mutations/merge_requests/update_spec.rb4
-rw-r--r--spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb60
-rw-r--r--spec/graphql/mutations/releases/create_spec.rb133
-rw-r--r--spec/graphql/mutations/terraform/state/delete_spec.rb55
-rw-r--r--spec/graphql/mutations/terraform/state/lock_spec.rb68
-rw-r--r--spec/graphql/mutations/terraform/state/unlock_spec.rb61
-rw-r--r--spec/graphql/mutations/todos/create_spec.rb44
-rw-r--r--spec/graphql/mutations/todos/mark_all_done_spec.rb2
-rw-r--r--spec/graphql/mutations/todos/restore_many_spec.rb19
-rw-r--r--spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb22
-rw-r--r--spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb38
-rw-r--r--spec/graphql/resolvers/base_resolver_spec.rb184
-rw-r--r--spec/graphql/resolvers/ci/jobs_resolver_spec.rb40
-rw-r--r--spec/graphql/resolvers/ci/runner_setup_resolver_spec.rb104
-rw-r--r--spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb208
-rw-r--r--spec/graphql/resolvers/container_repositories_resolver_spec.rb58
-rw-r--r--spec/graphql/resolvers/design_management/design_resolver_spec.rb15
-rw-r--r--spec/graphql/resolvers/design_management/designs_resolver_spec.rb24
-rw-r--r--spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb7
-rw-r--r--spec/graphql/resolvers/echo_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/group_members_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/issues_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb1
-rw-r--r--spec/graphql/resolvers/merge_requests_resolver_spec.rb8
-rw-r--r--spec/graphql/resolvers/metadata_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/project_pipeline_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/projects/services_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/projects/snippets_resolver_spec.rb6
-rw-r--r--spec/graphql/resolvers/projects_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/release_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/releases_resolver_spec.rb49
-rw-r--r--spec/graphql/resolvers/snippets/blobs_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/snippets_resolver_spec.rb10
-rw-r--r--spec/graphql/resolvers/todo_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/tree_resolver_spec.rb4
-rw-r--r--spec/graphql/resolvers/users/group_count_resolver_spec.rb62
-rw-r--r--spec/graphql/resolvers/users/snippets_resolver_spec.rb2
-rw-r--r--spec/graphql/resolvers/users_resolver_spec.rb16
-rw-r--r--spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb5
-rw-r--r--spec/graphql/types/alert_management/http_integration_type_spec.rb9
-rw-r--r--spec/graphql/types/alert_management/integration_type_enum_spec.rb22
-rw-r--r--spec/graphql/types/alert_management/integration_type_spec.rb21
-rw-r--r--spec/graphql/types/alert_management/prometheus_integration_type_spec.rb60
-rw-r--r--spec/graphql/types/availability_enum_spec.rb11
-rw-r--r--spec/graphql/types/ci/detailed_status_type_spec.rb21
-rw-r--r--spec/graphql/types/ci/job_type_spec.rb1
-rw-r--r--spec/graphql/types/ci/runner_setup_type_spec.rb16
-rw-r--r--spec/graphql/types/commit_type_spec.rb2
-rw-r--r--spec/graphql/types/container_repository_cleanup_status_enum_spec.rb13
-rw-r--r--spec/graphql/types/container_repository_details_type_spec.rb23
-rw-r--r--spec/graphql/types/container_repository_status_enum_spec.rb9
-rw-r--r--spec/graphql/types/container_repository_tag_type_spec.rb15
-rw-r--r--spec/graphql/types/container_repository_type_spec.rb31
-rw-r--r--spec/graphql/types/countable_connection_type_spec.rb2
-rw-r--r--spec/graphql/types/custom_emoji_type_spec.rb11
-rw-r--r--spec/graphql/types/environment_type_spec.rb8
-rw-r--r--spec/graphql/types/global_id_type_spec.rb3
-rw-r--r--spec/graphql/types/grafana_integration_type_spec.rb1
-rw-r--r--spec/graphql/types/group_invitation_type_spec.rb19
-rw-r--r--spec/graphql/types/invitation_interface_spec.rb43
-rw-r--r--spec/graphql/types/issue_type_spec.rb8
-rw-r--r--spec/graphql/types/merge_request_type_spec.rb4
-rw-r--r--spec/graphql/types/permission_types/note_spec.rb4
-rw-r--r--spec/graphql/types/project_invitation_type_spec.rb19
-rw-r--r--spec/graphql/types/project_statistics_type_spec.rb2
-rw-r--r--spec/graphql/types/project_type_spec.rb3
-rw-r--r--spec/graphql/types/projects/services_enum_spec.rb2
-rw-r--r--spec/graphql/types/query_type_spec.rb14
-rw-r--r--spec/graphql/types/release_asset_link_input_type_spec.rb15
-rw-r--r--spec/graphql/types/release_assets_input_type_spec.rb15
-rw-r--r--spec/graphql/types/release_links_type_spec.rb7
-rw-r--r--spec/graphql/types/root_storage_statistics_type_spec.rb2
-rw-r--r--spec/graphql/types/security/report_types_enum_spec.rb11
-rw-r--r--spec/graphql/types/terraform/state_type_spec.rb5
-rw-r--r--spec/graphql/types/terraform/state_version_type_spec.rb20
-rw-r--r--spec/graphql/types/user_status_type_spec.rb1
-rw-r--r--spec/graphql/types/user_type_spec.rb1
-rw-r--r--spec/helpers/application_settings_helper_spec.rb28
-rw-r--r--spec/helpers/auth_helper_spec.rb37
-rw-r--r--spec/helpers/blob_helper_spec.rb65
-rw-r--r--spec/helpers/branches_helper_spec.rb18
-rw-r--r--spec/helpers/breadcrumbs_helper_spec.rb145
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb30
-rw-r--r--spec/helpers/diff_helper_spec.rb58
-rw-r--r--spec/helpers/dropdowns_helper_spec.rb4
-rw-r--r--spec/helpers/gitlab_routing_helper_spec.rb10
-rw-r--r--spec/helpers/groups_helper_spec.rb41
-rw-r--r--spec/helpers/icons_helper_spec.rb6
-rw-r--r--spec/helpers/invite_members_helper_spec.rb120
-rw-r--r--spec/helpers/issuables_helper_spec.rb45
-rw-r--r--spec/helpers/markup_helper_spec.rb1
-rw-r--r--spec/helpers/operations_helper_spec.rb4
-rw-r--r--spec/helpers/page_layout_helper_spec.rb141
-rw-r--r--spec/helpers/profiles_helper_spec.rb32
-rw-r--r--spec/helpers/projects/terraform_helper_spec.rb23
-rw-r--r--spec/helpers/projects_helper_spec.rb11
-rw-r--r--spec/helpers/recaptcha_helper_spec.rb (renamed from spec/helpers/recaptcha_experiment_helper_spec.rb)2
-rw-r--r--spec/helpers/releases_helper_spec.rb6
-rw-r--r--spec/helpers/search_helper_spec.rb120
-rw-r--r--spec/helpers/sorting_helper_spec.rb18
-rw-r--r--spec/helpers/sourcegraph_helper_spec.rb37
-rw-r--r--spec/helpers/stat_anchors_helper_spec.rb53
-rw-r--r--spec/helpers/time_helper_spec.rb10
-rw-r--r--spec/helpers/todos_helper_spec.rb2
-rw-r--r--spec/helpers/tree_helper_spec.rb31
-rw-r--r--spec/helpers/user_callouts_helper_spec.rb46
-rw-r--r--spec/helpers/users_helper_spec.rb74
-rw-r--r--spec/helpers/whats_new_helper_spec.rb27
-rw-r--r--spec/lib/api/entities/merge_request_changes_spec.rb57
-rw-r--r--spec/lib/api/every_api_endpoint_spec.rb68
-rw-r--r--spec/lib/api/helpers_spec.rb6
-rw-r--r--spec/lib/api/validations/validators/email_or_email_list_spec.rb28
-rw-r--r--spec/lib/atlassian/jira_connect/client_spec.rb29
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/base_entity_spec.rb28
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb43
-rw-r--r--spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb18
-rw-r--r--spec/lib/backup/artifacts_spec.rb3
-rw-r--r--spec/lib/backup/files_spec.rb214
-rw-r--r--spec/lib/backup/pages_spec.rb3
-rw-r--r--spec/lib/backup/uploads_spec.rb3
-rw-r--r--spec/lib/banzai/filter/emoji_filter_spec.rb14
-rw-r--r--spec/lib/banzai/filter/normalize_source_filter_spec.rb26
-rw-r--r--spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb27
-rw-r--r--spec/lib/banzai/reference_parser/base_parser_spec.rb3
-rw-r--r--spec/lib/banzai/reference_parser/design_parser_spec.rb2
-rw-r--r--spec/lib/bitbucket_server/client_spec.rb9
-rw-r--r--spec/lib/bulk_imports/clients/http_spec.rb (renamed from spec/lib/gitlab/bulk_import/client_spec.rb)59
-rw-r--r--spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb75
-rw-r--r--spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb30
-rw-r--r--spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb88
-rw-r--r--spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb27
-rw-r--r--spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb81
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb102
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb82
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb105
-rw-r--r--spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb25
-rw-r--r--spec/lib/bulk_imports/importers/group_importer_spec.rb56
-rw-r--r--spec/lib/bulk_imports/importers/groups_importer_spec.rb36
-rw-r--r--spec/lib/bulk_imports/pipeline/attributes_spec.rb57
-rw-r--r--spec/lib/bulk_imports/pipeline/context_spec.rb27
-rw-r--r--spec/lib/bulk_imports/pipeline/runner_spec.rb74
-rw-r--r--spec/lib/container_registry/client_spec.rb163
-rw-r--r--spec/lib/csv_builders/stream_spec.rb42
-rw-r--r--spec/lib/expand_variables_spec.rb280
-rw-r--r--spec/lib/extracts_path_spec.rb13
-rw-r--r--spec/lib/extracts_ref_spec.rb17
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb4
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb2
-rw-r--r--spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb35
-rw-r--r--spec/lib/gitlab/auth/auth_finders_spec.rb1
-rw-r--r--spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb69
-rw-r--r--spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb65
-rw-r--r--spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb53
-rw-r--r--spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb98
-rw-r--r--spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb63
-rw-r--r--spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb65
-rw-r--r--spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb26
-rw-r--r--spec/lib/gitlab/badge/coverage/report_spec.rb103
-rw-r--r--spec/lib/gitlab/bitbucket_server_import/importer_spec.rb83
-rw-r--r--spec/lib/gitlab/chat/output_spec.rb99
-rw-r--r--spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb42
-rw-r--r--spec/lib/gitlab/ci/charts_spec.rb20
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb128
-rw-r--r--spec/lib/gitlab/ci/config/entry/product/variables_spec.rb71
-rw-r--r--spec/lib/gitlab/ci/config/external/mapper_spec.rb56
-rw-r--r--spec/lib/gitlab/ci/config/external/processor_spec.rb77
-rw-r--r--spec/lib/gitlab/ci/config_spec.rb78
-rw-r--r--spec/lib/gitlab/ci/jwt_spec.rb63
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb123
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb19
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb78
-rw-r--r--spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb66
-rw-r--r--spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb63
-rw-r--r--spec/lib/gitlab/ci/reports/test_case_spec.rb23
-rw-r--r--spec/lib/gitlab/ci/reports/test_failure_history_spec.rb45
-rw-r--r--spec/lib/gitlab/ci/reports/test_reports_spec.rb6
-rw-r--r--spec/lib/gitlab/ci/reports/test_suite_comparer_spec.rb146
-rw-r--r--spec/lib/gitlab/ci/reports/test_suite_spec.rb4
-rw-r--r--spec/lib/gitlab/ci/runner_instructions_spec.rb7
-rw-r--r--spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb59
-rw-r--r--spec/lib/gitlab/ci/templates/auto_devops_gitlab_ci_yaml_spec.rb13
-rw-r--r--spec/lib/gitlab/ci/variables/collection/item_spec.rb4
-rw-r--r--spec/lib/gitlab/config/entry/simplifiable_spec.rb6
-rw-r--r--spec/lib/gitlab/conflict/file_spec.rb45
-rw-r--r--spec/lib/gitlab/cycle_analytics/events_spec.rb2
-rw-r--r--spec/lib/gitlab/danger/commit_linter_spec.rb7
-rw-r--r--spec/lib/gitlab/danger/helper_spec.rb23
-rw-r--r--spec/lib/gitlab/data_builder/feature_flag_spec.rb25
-rw-r--r--spec/lib/gitlab/database/batch_count_spec.rb23
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb2
-rw-r--r--spec/lib/gitlab/database/partitioning/replace_table_spec.rb113
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb186
-rw-r--r--spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb71
-rw-r--r--spec/lib/gitlab/database/postgres_index_spec.rb44
-rw-r--r--spec/lib/gitlab/database/postgres_partition_spec.rb75
-rw-r--r--spec/lib/gitlab/database/postgres_partitioned_table_spec.rb98
-rw-r--r--spec/lib/gitlab/database/reindexing_spec.rb2
-rw-r--r--spec/lib/gitlab/email/handler/service_desk_handler_spec.rb19
-rw-r--r--spec/lib/gitlab/error_tracking_spec.rb98
-rw-r--r--spec/lib/gitlab/etag_caching/middleware_spec.rb48
-rw-r--r--spec/lib/gitlab/etag_caching/router_spec.rb8
-rw-r--r--spec/lib/gitlab/exclusive_lease_helpers_spec.rb8
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb438
-rw-r--r--spec/lib/gitlab/experimentation/group_types_spec.rb13
-rw-r--r--spec/lib/gitlab/experimentation_spec.rb443
-rw-r--r--spec/lib/gitlab/git/diff_spec.rb14
-rw-r--r--spec/lib/gitlab/git_access_snippet_spec.rb4
-rw-r--r--spec/lib/gitlab/gitaly_client/commit_service_spec.rb4
-rw-r--r--spec/lib/gitlab/gitaly_client/repository_service_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/client_spec.rb129
-rw-r--r--spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import/sequential_importer_spec.rb2
-rw-r--r--spec/lib/gitlab/github_import_spec.rb57
-rw-r--r--spec/lib/gitlab/grape_logging/formatters/lograge_with_timestamp_spec.rb6
-rw-r--r--spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb226
-rw-r--r--spec/lib/gitlab/graphql/lazy_spec.rb96
-rw-r--r--spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb23
-rw-r--r--spec/lib/gitlab/hook_data/release_builder_spec.rb49
-rw-r--r--spec/lib/gitlab/i18n/po_linter_spec.rb5
-rw-r--r--spec/lib/gitlab/import_export/all_models.yml6
-rw-r--r--spec/lib/gitlab/import_export/attributes_finder_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/group/legacy_tree_saver_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/importer_spec.rb20
-rw-r--r--spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb18
-rw-r--r--spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb1
-rw-r--r--spec/lib/gitlab/import_export/lfs_restorer_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/project/relation_factory_spec.rb1
-rw-r--r--spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb2
-rw-r--r--spec/lib/gitlab/import_export/project/sample/relation_factory_spec.rb168
-rw-r--r--spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb (renamed from spec/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer_spec.rb)23
-rw-r--r--spec/lib/gitlab/import_export/project/tree_restorer_spec.rb44
-rw-r--r--spec/lib/gitlab/import_export/safe_model_attributes.yml26
-rw-r--r--spec/lib/gitlab/import_export/uploads_manager_spec.rb24
-rw-r--r--spec/lib/gitlab/instrumentation_helper_spec.rb10
-rw-r--r--spec/lib/gitlab/jira_import_spec.rb2
-rw-r--r--spec/lib/gitlab/json_spec.rb518
-rw-r--r--spec/lib/gitlab/kubernetes/helm/api_spec.rb4
-rw-r--r--spec/lib/gitlab/kubernetes/helm/pod_spec.rb121
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb50
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/certificate_spec.rb)2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb)2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/init_command_spec.rb)2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/install_command_spec.rb)33
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb)29
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb)2
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb (renamed from spec/lib/gitlab/kubernetes/helm/base_command_spec.rb)10
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb35
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb168
-rw-r--r--spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb81
-rw-r--r--spec/lib/gitlab/kubernetes/kube_client_spec.rb32
-rw-r--r--spec/lib/gitlab/legacy_github_import/importer_spec.rb5
-rw-r--r--spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb118
-rw-r--r--spec/lib/gitlab/middleware/handle_malformed_strings_spec.rb182
-rw-r--r--spec/lib/gitlab/middleware/handle_null_bytes_spec.rb88
-rw-r--r--spec/lib/gitlab/middleware/read_only_spec.rb202
-rw-r--r--spec/lib/gitlab/omniauth_initializer_spec.rb28
-rw-r--r--spec/lib/gitlab/path_regex_spec.rb39
-rw-r--r--spec/lib/gitlab/quick_actions/extractor_spec.rb16
-rw-r--r--spec/lib/gitlab/redis/wrapper_spec.rb6
-rw-r--r--spec/lib/gitlab/reference_extractor_spec.rb2
-rw-r--r--spec/lib/gitlab/regex_spec.rb9
-rw-r--r--spec/lib/gitlab/relative_positioning/mover_spec.rb1
-rw-r--r--spec/lib/gitlab/repository_size_checker_spec.rb6
-rw-r--r--spec/lib/gitlab/repository_size_error_message_spec.rb14
-rw-r--r--spec/lib/gitlab/robots_txt/parser_spec.rb15
-rw-r--r--spec/lib/gitlab/search/sort_options_spec.rb34
-rw-r--r--spec/lib/gitlab/sidekiq_cluster/cli_spec.rb165
-rw-r--r--spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb20
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/client_spec.rb109
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb64
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb20
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb144
-rw-r--r--spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb4
-rw-r--r--spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb20
-rw-r--r--spec/lib/gitlab/throttle_spec.rb18
-rw-r--r--spec/lib/gitlab/tracking/destinations/snowplow_spec.rb78
-rw-r--r--spec/lib/gitlab/tracking/incident_management_spec.rb2
-rw-r--r--spec/lib/gitlab/tracking_spec.rb106
-rw-r--r--spec/lib/gitlab/url_blocker_spec.rb30
-rw-r--r--spec/lib/gitlab/url_blockers/domain_allowlist_entry_spec.rb58
-rw-r--r--spec/lib/gitlab/url_blockers/domain_whitelist_entry_spec.rb58
-rw-r--r--spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb75
-rw-r--r--spec/lib/gitlab/url_blockers/ip_whitelist_entry_spec.rb75
-rw-r--r--spec/lib/gitlab/url_blockers/url_allowlist_spec.rb164
-rw-r--r--spec/lib/gitlab/url_blockers/url_whitelist_spec.rb164
-rw-r--r--spec/lib/gitlab/url_builder_spec.rb1
-rw-r--r--spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb57
-rw-r--r--spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb324
-rw-r--r--spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb138
-rw-r--r--spec/lib/gitlab/usage_data_counters/static_site_editor_counter_spec.rb6
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb174
-rw-r--r--spec/lib/gitlab/with_feature_category_spec.rb (renamed from spec/controllers/concerns/controller_with_feature_category_spec.rb)15
-rw-r--r--spec/lib/quality/test_level_spec.rb18
-rw-r--r--spec/mailers/devise_mailer_spec.rb30
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb3
-rw-r--r--spec/mailers/emails/projects_spec.rb36
-rw-r--r--spec/mailers/emails/service_desk_spec.rb19
-rw-r--r--spec/mailers/notify_spec.rb197
-rw-r--r--spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb2
-rw-r--r--spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb2
-rw-r--r--spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb26
-rw-r--r--spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb38
-rw-r--r--spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb12
-rw-r--r--spec/migrations/deduplicate_epic_iids_spec.rb36
-rw-r--r--spec/migrations/generate_ci_jwt_signing_key_spec.rb42
-rw-r--r--spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb2
-rw-r--r--spec/migrations/rename_sitemap_namespace_spec.rb30
-rw-r--r--spec/migrations/rename_sitemap_root_namespaces_spec.rb36
-rw-r--r--spec/migrations/reseed_merge_trains_enabled_spec.rb26
-rw-r--r--spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb (renamed from spec/migrations/schedule_blocked_by_links_replacement_spec.rb)4
-rw-r--r--spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb41
-rw-r--r--spec/migrations/schedule_populate_has_vulnerabilities_spec.rb36
-rw-r--r--spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb37
-rw-r--r--spec/migrations/seed_merge_trains_enabled_spec.rb28
-rw-r--r--spec/migrations/update_historical_data_recorded_at_spec.rb31
-rw-r--r--spec/models/alert_management/http_integration_spec.rb83
-rw-r--r--spec/models/analytics/cycle_analytics/project_stage_spec.rb2
-rw-r--r--spec/models/analytics/devops_adoption/segment_selection_spec.rb69
-rw-r--r--spec/models/analytics/instance_statistics/measurement_spec.rb77
-rw-r--r--spec/models/application_record_spec.rb11
-rw-r--r--spec/models/application_setting_spec.rb32
-rw-r--r--spec/models/authentication_event_spec.rb8
-rw-r--r--spec/models/broadcast_message_spec.rb6
-rw-r--r--spec/models/bulk_imports/tracker_spec.rb27
-rw-r--r--spec/models/ci/bridge_spec.rb60
-rw-r--r--spec/models/ci/build_spec.rb19
-rw-r--r--spec/models/ci/build_trace_chunk_spec.rb66
-rw-r--r--spec/models/ci/build_trace_chunks/legacy_fog_spec.rb164
-rw-r--r--spec/models/ci/daily_build_group_report_result_spec.rb77
-rw-r--r--spec/models/ci/pipeline_spec.rb28
-rw-r--r--spec/models/ci/test_case_failure_spec.rb73
-rw-r--r--spec/models/ci/test_case_spec.rb31
-rw-r--r--spec/models/clusters/agent_token_spec.rb5
-rw-r--r--spec/models/clusters/applications/cert_manager_spec.rb4
-rw-r--r--spec/models/clusters/applications/crossplane_spec.rb2
-rw-r--r--spec/models/clusters/applications/elastic_stack_spec.rb8
-rw-r--r--spec/models/clusters/applications/fluentd_spec.rb2
-rw-r--r--spec/models/clusters/applications/helm_spec.rb6
-rw-r--r--spec/models/clusters/applications/ingress_spec.rb2
-rw-r--r--spec/models/clusters/applications/jupyter_spec.rb2
-rw-r--r--spec/models/clusters/applications/knative_spec.rb4
-rw-r--r--spec/models/clusters/applications/prometheus_spec.rb6
-rw-r--r--spec/models/clusters/applications/runner_spec.rb2
-rw-r--r--spec/models/clusters/cluster_spec.rb21
-rw-r--r--spec/models/commit_status_spec.rb137
-rw-r--r--spec/models/concerns/atomic_internal_id_spec.rb16
-rw-r--r--spec/models/concerns/from_union_spec.rb10
-rw-r--r--spec/models/concerns/optionally_search_spec.rb2
-rw-r--r--spec/models/container_expiration_policy_spec.rb55
-rw-r--r--spec/models/container_repository_spec.rb16
-rw-r--r--spec/models/custom_emoji_spec.rb14
-rw-r--r--spec/models/dependency_proxy/blob_spec.rb55
-rw-r--r--spec/models/dependency_proxy/group_setting_spec.rb13
-rw-r--r--spec/models/dependency_proxy/registry_spec.rb57
-rw-r--r--spec/models/deploy_key_spec.rb53
-rw-r--r--spec/models/deploy_keys_project_spec.rb15
-rw-r--r--spec/models/deploy_token_spec.rb33
-rw-r--r--spec/models/deployment_spec.rb8
-rw-r--r--spec/models/design_management/design_at_version_spec.rb2
-rw-r--r--spec/models/design_management/design_spec.rb52
-rw-r--r--spec/models/design_management/version_spec.rb2
-rw-r--r--spec/models/diff_viewer/image_spec.rb40
-rw-r--r--spec/models/environment_spec.rb16
-rw-r--r--spec/models/experiment_spec.rb120
-rw-r--r--spec/models/group_spec.rb143
-rw-r--r--spec/models/instance_metadata_spec.rb12
-rw-r--r--spec/models/internal_id_spec.rb23
-rw-r--r--spec/models/issue_link_spec.rb9
-rw-r--r--spec/models/issues/csv_import_spec.rb10
-rw-r--r--spec/models/key_spec.rb4
-rw-r--r--spec/models/member_spec.rb11
-rw-r--r--spec/models/members/group_member_spec.rb14
-rw-r--r--spec/models/merge_request/cleanup_schedule_spec.rb32
-rw-r--r--spec/models/merge_request_spec.rb13
-rw-r--r--spec/models/namespace/root_storage_statistics_spec.rb2
-rw-r--r--spec/models/namespace_setting_spec.rb7
-rw-r--r--spec/models/namespace_spec.rb95
-rw-r--r--spec/models/operations/feature_flag_spec.rb34
-rw-r--r--spec/models/operations/feature_flags/user_list_spec.rb19
-rw-r--r--spec/models/packages/build_info_spec.rb9
-rw-r--r--spec/models/packages/package_file_build_info_spec.rb9
-rw-r--r--spec/models/packages/package_file_spec.rb2
-rw-r--r--spec/models/packages/package_spec.rb32
-rw-r--r--spec/models/pages/lookup_path_spec.rb120
-rw-r--r--spec/models/pages_deployment_spec.rb36
-rw-r--r--spec/models/personal_access_token_spec.rb12
-rw-r--r--spec/models/personal_snippet_spec.rb3
-rw-r--r--spec/models/project_snippet_spec.rb3
-rw-r--r--spec/models/project_spec.rb99
-rw-r--r--spec/models/project_statistics_spec.rb40
-rw-r--r--spec/models/protected_branch/push_access_level_spec.rb30
-rw-r--r--spec/models/route_spec.rb9
-rw-r--r--spec/models/service_spec.rb116
-rw-r--r--spec/models/terraform/state_spec.rb8
-rw-r--r--spec/models/terraform/state_version_spec.rb1
-rw-r--r--spec/models/user_spec.rb78
-rw-r--r--spec/policies/alert_management/http_integration_policy_spec.rb23
-rw-r--r--spec/policies/base_policy_spec.rb74
-rw-r--r--spec/policies/blob_policy_spec.rb5
-rw-r--r--spec/policies/group_member_policy_spec.rb1
-rw-r--r--spec/policies/group_policy_spec.rb20
-rw-r--r--spec/policies/instance_metadata_policy_spec.rb19
-rw-r--r--spec/policies/issue_policy_spec.rb7
-rw-r--r--spec/policies/merge_request_policy_spec.rb13
-rw-r--r--spec/policies/note_policy_spec.rb117
-rw-r--r--spec/policies/project_policy_spec.rb1
-rw-r--r--spec/policies/service_policy_spec.rb26
-rw-r--r--spec/policies/terraform/state_version_policy_spec.rb33
-rw-r--r--spec/policies/user_policy_spec.rb58
-rw-r--r--spec/policies/wiki_page_policy_spec.rb5
-rw-r--r--spec/presenters/issue_presenter_spec.rb16
-rw-r--r--spec/presenters/packages/detail/package_presenter_spec.rb34
-rw-r--r--spec/presenters/release_presenter_spec.rb73
-rw-r--r--spec/requests/api/admin/instance_clusters_spec.rb1
-rw-r--r--spec/requests/api/api_spec.rb32
-rw-r--r--spec/requests/api/boards_spec.rb43
-rw-r--r--spec/requests/api/ci/pipelines_spec.rb615
-rw-r--r--spec/requests/api/commits_spec.rb11
-rw-r--r--spec/requests/api/container_repositories_spec.rb88
-rw-r--r--spec/requests/api/dependency_proxy_spec.rb72
-rw-r--r--spec/requests/api/feature_flags_user_lists_spec.rb33
-rw-r--r--spec/requests/api/files_spec.rb70
-rw-r--r--spec/requests/api/generic_packages_spec.rb4
-rw-r--r--spec/requests/api/graphql/ci/jobs_spec.rb28
-rw-r--r--spec/requests/api/graphql/ci/pipelines_spec.rb221
-rw-r--r--spec/requests/api/graphql/container_repository/container_repository_details_spec.rb108
-rw-r--r--spec/requests/api/graphql/custom_emoji_query_spec.rb37
-rw-r--r--spec/requests/api/graphql/group/container_repositories_spec.rb146
-rw-r--r--spec/requests/api/graphql/instance_statistics_measurements_spec.rb13
-rw-r--r--spec/requests/api/graphql/issue/issue_spec.rb24
-rw-r--r--spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb1
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/http_integration/create_spec.rb67
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/http_integration/destroy_spec.rb55
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/http_integration/reset_token_spec.rb45
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/http_integration/update_spec.rb49
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb67
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb59
-rw-r--r--spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb47
-rw-r--r--spec/requests/api/graphql/mutations/commits/create_spec.rb38
-rw-r--r--spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb25
-rw-r--r--spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb91
-rw-r--r--spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb43
-rw-r--r--spec/requests/api/graphql/mutations/labels/create_spec.rb86
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb49
-rw-r--r--spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb2
-rw-r--r--spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb81
-rw-r--r--spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb8
-rw-r--r--spec/requests/api/graphql/mutations/releases/create_spec.rb375
-rw-r--r--spec/requests/api/graphql/mutations/snippets/destroy_spec.rb5
-rw-r--r--spec/requests/api/graphql/mutations/todos/create_spec.rb38
-rw-r--r--spec/requests/api/graphql/mutations/todos/restore_many_spec.rb70
-rw-r--r--spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/alert_management/integrations_spec.rb83
-rw-r--r--spec/requests/api/graphql/project/container_repositories_spec.rb145
-rw-r--r--spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb12
-rw-r--r--spec/requests/api/graphql/project/grafana_integration_spec.rb1
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/version_spec.rb1
-rw-r--r--spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb3
-rw-r--r--spec/requests/api/graphql/project/issue/designs/notes_spec.rb4
-rw-r--r--spec/requests/api/graphql/project/issues_spec.rb45
-rw-r--r--spec/requests/api/graphql/project/jira_import_spec.rb2
-rw-r--r--spec/requests/api/graphql/project/merge_requests_spec.rb11
-rw-r--r--spec/requests/api/graphql/project/project_statistics_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/release_spec.rb22
-rw-r--r--spec/requests/api/graphql/project/releases_spec.rb92
-rw-r--r--spec/requests/api/graphql/project/terraform/states_spec.rb81
-rw-r--r--spec/requests/api/graphql/read_only_spec.rb50
-rw-r--r--spec/requests/api/graphql/terraform/state/delete_spec.rb23
-rw-r--r--spec/requests/api/graphql/terraform/state/lock_spec.rb25
-rw-r--r--spec/requests/api/graphql/terraform/state/unlock_spec.rb24
-rw-r--r--spec/requests/api/graphql/user/group_member_query_spec.rb1
-rw-r--r--spec/requests/api/graphql/user/project_member_query_spec.rb1
-rw-r--r--spec/requests/api/graphql/user_query_spec.rb14
-rw-r--r--spec/requests/api/graphql_spec.rb2
-rw-r--r--spec/requests/api/group_labels_spec.rb129
-rw-r--r--spec/requests/api/import_github_spec.rb16
-rw-r--r--spec/requests/api/internal/base_spec.rb28
-rw-r--r--spec/requests/api/internal/pages_spec.rb50
-rw-r--r--spec/requests/api/invitations_spec.rb301
-rw-r--r--spec/requests/api/issues/get_project_issues_spec.rb2
-rw-r--r--spec/requests/api/issues/issues_spec.rb2
-rw-r--r--spec/requests/api/issues/post_projects_issues_spec.rb2
-rw-r--r--spec/requests/api/labels_spec.rb60
-rw-r--r--spec/requests/api/lint_spec.rb210
-rw-r--r--spec/requests/api/maven_packages_spec.rb42
-rw-r--r--spec/requests/api/members_spec.rb2
-rw-r--r--spec/requests/api/merge_requests_spec.rb88
-rw-r--r--spec/requests/api/npm_instance_packages_spec.rb31
-rw-r--r--spec/requests/api/npm_packages_spec.rb556
-rw-r--r--spec/requests/api/npm_project_packages_spec.rb281
-rw-r--r--spec/requests/api/personal_access_tokens_spec.rb112
-rw-r--r--spec/requests/api/project_container_repositories_spec.rb6
-rw-r--r--spec/requests/api/project_export_spec.rb2
-rw-r--r--spec/requests/api/project_hooks_spec.rb6
-rw-r--r--spec/requests/api/projects_spec.rb5
-rw-r--r--spec/requests/api/release/links_spec.rb2
-rw-r--r--spec/requests/api/releases_spec.rb27
-rw-r--r--spec/requests/api/search_spec.rb63
-rw-r--r--spec/requests/api/settings_spec.rb45
-rw-r--r--spec/requests/api/terraform/state_spec.rb14
-rw-r--r--spec/requests/api/users_spec.rb222
-rw-r--r--spec/requests/git_http_spec.rb22
-rw-r--r--spec/requests/lfs_http_spec.rb428
-rw-r--r--spec/requests/lfs_locks_api_spec.rb61
-rw-r--r--spec/requests/projects/metrics/dashboards/builder_spec.rb1
-rw-r--r--spec/requests/projects/noteable_notes_spec.rb40
-rw-r--r--spec/requests/projects/releases_controller_spec.rb60
-rw-r--r--spec/requests/rack_attack_global_spec.rb61
-rw-r--r--spec/requests/robots_txt_spec.rb93
-rw-r--r--spec/requests/search_controller_spec.rb82
-rw-r--r--spec/requests/user_sends_malformed_strings_spec.rb28
-rw-r--r--spec/requests/user_sends_null_bytes_spec.rb14
-rw-r--r--spec/requests/whats_new_controller_spec.rb39
-rw-r--r--spec/routing/group_routing_spec.rb36
-rw-r--r--spec/routing/openid_connect_spec.rb5
-rw-r--r--spec/routing/routing_spec.rb7
-rw-r--r--spec/rubocop/cop/code_reuse/active_record_spec.rb134
-rw-r--r--spec/rubocop/cop/graphql/resolver_type_spec.rb74
-rw-r--r--spec/rubocop/cop/line_break_around_conditional_block_spec.rb454
-rw-r--r--spec/rubocop/cop/rspec/be_success_matcher_spec.rb2
-rw-r--r--spec/serializers/base_discussion_entity_spec.rb69
-rw-r--r--spec/serializers/diff_file_entity_spec.rb11
-rw-r--r--spec/serializers/diffs_entity_spec.rb50
-rw-r--r--spec/serializers/discussion_entity_spec.rb4
-rw-r--r--spec/serializers/environment_entity_spec.rb2
-rw-r--r--spec/serializers/merge_request_widget_entity_spec.rb27
-rw-r--r--spec/serializers/move_to_project_entity_spec.rb6
-rw-r--r--spec/serializers/paginated_diff_entity_spec.rb46
-rw-r--r--spec/serializers/test_case_entity_spec.rb13
-rw-r--r--spec/serializers/test_suite_comparer_entity_spec.rb104
-rw-r--r--spec/services/admin/propagate_integration_service_spec.rb15
-rw-r--r--spec/services/alert_management/create_alert_issue_service_spec.rb1
-rw-r--r--spec/services/alert_management/http_integrations/create_service_spec.rb66
-rw-r--r--spec/services/alert_management/http_integrations/destroy_service_spec.rb63
-rw-r--r--spec/services/alert_management/http_integrations/update_service_spec.rb77
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb56
-rw-r--r--spec/services/alert_management/sync_alert_service_data_service_spec.rb55
-rw-r--r--spec/services/application_settings/update_service_spec.rb10
-rw-r--r--spec/services/audit_event_service_spec.rb35
-rw-r--r--spec/services/auth/container_registry_authentication_service_spec.rb131
-rw-r--r--spec/services/auto_merge/base_service_spec.rb2
-rw-r--r--spec/services/auto_merge_service_spec.rb2
-rw-r--r--spec/services/bulk_create_integration_service_spec.rb94
-rw-r--r--spec/services/bulk_import_service_spec.rb52
-rw-r--r--spec/services/bulk_update_integration_service_spec.rb74
-rw-r--r--spec/services/ci/append_build_trace_service_spec.rb57
-rw-r--r--spec/services/ci/build_report_result_service_spec.rb12
-rw-r--r--spec/services/ci/compare_test_reports_service_spec.rb51
-rw-r--r--spec/services/ci/create_downstream_pipeline_service_spec.rb35
-rw-r--r--spec/services/ci/create_pipeline_service/cache_spec.rb4
-rw-r--r--spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb4
-rw-r--r--spec/services/ci/create_pipeline_service/custom_config_content_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/dry_run_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/needs_spec.rb5
-rw-r--r--spec/services/ci/create_pipeline_service/parameter_content_spec.rb2
-rw-r--r--spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb34
-rw-r--r--spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb4
-rw-r--r--spec/services/ci/create_pipeline_service/rules_spec.rb4
-rw-r--r--spec/services/ci/create_pipeline_service_spec.rb227
-rw-r--r--spec/services/ci/daily_build_group_report_result_service_spec.rb27
-rw-r--r--spec/services/ci/destroy_expired_job_artifacts_service_spec.rb115
-rw-r--r--spec/services/ci/list_config_variables_service_spec.rb39
-rw-r--r--spec/services/ci/test_cases_service_spec.rb94
-rw-r--r--spec/services/clusters/applications/check_installation_progress_service_spec.rb6
-rw-r--r--spec/services/clusters/applications/uninstall_service_spec.rb6
-rw-r--r--spec/services/clusters/aws/authorize_role_service_spec.rb6
-rw-r--r--spec/services/clusters/aws/fetch_credentials_service_spec.rb13
-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.rb71
-rw-r--r--spec/services/clusters/update_service_spec.rb4
-rw-r--r--spec/services/container_expiration_policies/cleanup_service_spec.rb62
-rw-r--r--spec/services/container_expiration_policy_service_spec.rb15
-rw-r--r--spec/services/dependency_proxy/download_blob_service_spec.rb44
-rw-r--r--spec/services/dependency_proxy/find_or_create_blob_service_spec.rb53
-rw-r--r--spec/services/dependency_proxy/pull_manifest_service_spec.rb44
-rw-r--r--spec/services/dependency_proxy/request_token_service_spec.rb52
-rw-r--r--spec/services/deploy_keys/collect_keys_service_spec.rb58
-rw-r--r--spec/services/design_management/copy_design_collection/copy_service_spec.rb37
-rw-r--r--spec/services/design_management/generate_image_versions_service_spec.rb2
-rw-r--r--spec/services/discussions/resolve_service_spec.rb4
-rw-r--r--spec/services/draft_notes/destroy_service_spec.rb4
-rw-r--r--spec/services/emails/confirm_service_spec.rb2
-rw-r--r--spec/services/feature_flags/update_service_spec.rb7
-rw-r--r--spec/services/groups/destroy_service_spec.rb2
-rw-r--r--spec/services/groups/import_export/import_service_spec.rb6
-rw-r--r--spec/services/issuable/common_system_notes_service_spec.rb8
-rw-r--r--spec/services/issues/import_csv_service_spec.rb103
-rw-r--r--spec/services/issues/move_service_spec.rb35
-rw-r--r--spec/services/issues/related_branches_service_spec.rb12
-rw-r--r--spec/services/issues/zoom_link_service_spec.rb22
-rw-r--r--spec/services/jira_connect/sync_service_spec.rb3
-rw-r--r--spec/services/jira_connect_subscriptions/create_service_spec.rb30
-rw-r--r--spec/services/jira_import/cloud_users_mapper_service_spec.rb35
-rw-r--r--spec/services/jira_import/server_users_mapper_service_spec.rb35
-rw-r--r--spec/services/jira_import/users_importer_spec.rb34
-rw-r--r--spec/services/labels/promote_service_spec.rb206
-rw-r--r--spec/services/labels/transfer_service_spec.rb7
-rw-r--r--spec/services/members/invite_service_spec.rb66
-rw-r--r--spec/services/merge_requests/add_context_service_spec.rb16
-rw-r--r--spec/services/merge_requests/cleanup_refs_service_spec.rb56
-rw-r--r--spec/services/merge_requests/refresh_service_spec.rb12
-rw-r--r--spec/services/merge_requests/reopen_service_spec.rb10
-rw-r--r--spec/services/metrics/dashboard/panel_preview_service_spec.rb1
-rw-r--r--spec/services/notes/create_service_spec.rb253
-rw-r--r--spec/services/notes/destroy_service_spec.rb55
-rw-r--r--spec/services/notes/update_service_spec.rb28
-rw-r--r--spec/services/notification_recipients/build_service_spec.rb4
-rw-r--r--spec/services/notification_service_spec.rb138
-rw-r--r--spec/services/packages/composer/version_parser_service_spec.rb2
-rw-r--r--spec/services/packages/conan/create_package_file_service_spec.rb2
-rw-r--r--spec/services/packages/create_event_service_spec.rb78
-rw-r--r--spec/services/packages/create_package_file_service_spec.rb19
-rw-r--r--spec/services/packages/debian/extract_deb_metadata_service_spec.rb37
-rw-r--r--spec/services/packages/debian/parse_debian822_service_spec.rb148
-rw-r--r--spec/services/packages/generic/create_package_file_service_spec.rb6
-rw-r--r--spec/services/packages/generic/find_or_create_package_service_spec.rb10
-rw-r--r--spec/services/packages/maven/create_package_service_spec.rb2
-rw-r--r--spec/services/packages/maven/find_or_create_package_service_spec.rb17
-rw-r--r--spec/services/packages/npm/create_package_service_spec.rb11
-rw-r--r--spec/services/pages/destroy_deployments_service_spec.rb29
-rw-r--r--spec/services/personal_access_tokens/create_service_spec.rb66
-rw-r--r--spec/services/personal_access_tokens/revoke_service_spec.rb20
-rw-r--r--spec/services/post_receive_service_spec.rb43
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb30
-rw-r--r--spec/services/projects/autocomplete_service_spec.rb28
-rw-r--r--spec/services/projects/cleanup_service_spec.rb87
-rw-r--r--spec/services/projects/container_repository/delete_tags_service_spec.rb18
-rw-r--r--spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb2
-rw-r--r--spec/services/projects/create_service_spec.rb35
-rw-r--r--spec/services/projects/lfs_pointers/lfs_download_service_spec.rb12
-rw-r--r--spec/services/projects/update_pages_service_spec.rb59
-rw-r--r--spec/services/projects/update_repository_storage_service_spec.rb18
-rw-r--r--spec/services/projects/update_service_spec.rb50
-rw-r--r--spec/services/quick_actions/interpret_service_spec.rb32
-rw-r--r--spec/services/releases/create_service_spec.rb6
-rw-r--r--spec/services/releases/update_service_spec.rb6
-rw-r--r--spec/services/reset_project_cache_service_spec.rb2
-rw-r--r--spec/services/resource_access_tokens/create_service_spec.rb70
-rw-r--r--spec/services/resource_events/change_milestone_service_spec.rb2
-rw-r--r--spec/services/search/snippet_service_spec.rb22
-rw-r--r--spec/services/search_service_spec.rb37
-rw-r--r--spec/services/snippets/create_service_spec.rb6
-rw-r--r--spec/services/snippets/update_service_spec.rb6
-rw-r--r--spec/services/system_hooks_service_spec.rb2
-rw-r--r--spec/services/system_note_service_spec.rb16
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb8
-rw-r--r--spec/services/system_notes/merge_requests_service_spec.rb20
-rw-r--r--spec/services/test_hooks/project_service_spec.rb18
-rw-r--r--spec/services/todo_service_spec.rb50
-rw-r--r--spec/services/todos/destroy/confidential_issue_service_spec.rb2
-rw-r--r--spec/services/two_factor/destroy_service_spec.rb2
-rw-r--r--spec/services/users/approve_service_spec.rb118
-rw-r--r--spec/services/users/destroy_service_spec.rb88
-rw-r--r--spec/services/users/repair_ldap_blocked_service_spec.rb2
-rw-r--r--spec/services/users/set_status_service_spec.rb25
-rw-r--r--spec/services/verify_pages_domain_service_spec.rb2
-rw-r--r--spec/services/web_hook_service_spec.rb1
-rw-r--r--spec/sidekiq/cron/job_gem_dependency_spec.rb2
-rw-r--r--spec/spec_helper.rb18
-rw-r--r--spec/support/cycle_analytics_helpers/test_generation.rb6
-rw-r--r--spec/support/helpers/admin_mode_helpers.rb2
-rw-r--r--spec/support/helpers/api_helpers.rb1
-rw-r--r--spec/support/helpers/dependency_proxy_helpers.rb33
-rw-r--r--spec/support/helpers/features/members_table_helpers.rb37
-rw-r--r--spec/support/helpers/features/releases_helpers.rb4
-rw-r--r--spec/support/helpers/features/web_ide_spec_helpers.rb41
-rw-r--r--spec/support/helpers/graphql_helpers.rb25
-rw-r--r--spec/support/helpers/kubernetes_helpers.rb21
-rw-r--r--spec/support/helpers/lfs_http_helpers.rb12
-rw-r--r--spec/support/helpers/navbar_structure_helper.rb8
-rw-r--r--spec/support/helpers/require_migration.rb24
-rw-r--r--spec/support/helpers/search_helpers.rb3
-rw-r--r--spec/support/helpers/snowplow_helpers.rb14
-rw-r--r--spec/support/helpers/table_schema_helpers.rb112
-rw-r--r--spec/support/helpers/test_env.rb16
-rw-r--r--spec/support/helpers/usage_data_helpers.rb1
-rw-r--r--spec/support/helpers/user_login_helper.rb23
-rw-r--r--spec/support/helpers/wiki_helpers.rb1
-rw-r--r--spec/support/import_export/common_util.rb2
-rw-r--r--spec/support/matchers/graphql_matchers.rb77
-rw-r--r--spec/support/patches/rspec_mocks_prepended_methods.rb55
-rw-r--r--spec/support/rspec.rb7
-rw-r--r--spec/support/services/issuable_import_csv_service_shared_examples.rb142
-rw-r--r--spec/support/shared_contexts/design_management_shared_contexts.rb2
-rw-r--r--spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb20
-rw-r--r--spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb3
-rw-r--r--spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb32
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb2
-rw-r--r--spec/support/shared_contexts/policies/project_policy_table_shared_context.rb903
-rw-r--r--spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb1
-rw-r--r--spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb22
-rw-r--r--spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/cached_response_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb18
-rw-r--r--spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb29
-rw-r--r--spec/support/shared_examples/controllers/trackable_shared_examples.rb10
-rw-r--r--spec/support/shared_examples/features/discussion_comments_shared_example.rb12
-rw-r--r--spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb6
-rw-r--r--spec/support/shared_examples/features/variable_list_shared_examples.rb430
-rw-r--r--spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/finders/security/jobs_finder_shared_examples.rb87
-rw-r--r--spec/support/shared_examples/graphql/label_fields.rb4
-rw-r--r--spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/graphql/mutations/create_todo_shared_examples.rb26
-rw-r--r--spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb52
-rw-r--r--spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb73
-rw-r--r--spec/support/shared_examples/helm_commands_shared_examples.rb12
-rw-r--r--spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb23
-rw-r--r--spec/support/shared_examples/lib/gitlab/database/postgres_model_shared_examples.rb35
-rw-r--r--spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb189
-rw-r--r--spec/support/shared_examples/lib/gitlab/repository_size_checker_shared_examples.rb59
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb57
-rw-r--r--spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb139
-rw-r--r--spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb35
-rw-r--r--spec/support/shared_examples/mailers/notify_shared_examples.rb9
-rw-r--r--spec/support/shared_examples/models/application_setting_shared_examples.rb56
-rw-r--r--spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb20
-rw-r--r--spec/support/shared_examples/models/cluster_application_core_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/read_only_message_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/requests/api/discussions_shared_examples.rb17
-rw-r--r--spec/support/shared_examples/requests/api/graphql/read_only_instance_shared_examples.rb47
-rw-r--r--spec/support/shared_examples/requests/api/labels_api_shared_examples.rb14
-rw-r--r--spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb94
-rw-r--r--spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb270
-rw-r--r--spec/support/shared_examples/requests/api/packages_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/api/tracking_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/requests/lfs_http_shared_examples.rb224
-rw-r--r--spec/support/shared_examples/requests/rack_attack_shared_examples.rb37
-rw-r--r--spec/support/shared_examples/serializers/note_entity_shared_examples.rb50
-rw-r--r--spec/support/shared_examples/services/alert_management_shared_examples.rb6
-rw-r--r--spec/support/shared_examples/services/common_system_notes_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/services/jira_import/user_mapper_services_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/services/packages_shared_examples.rb4
-rw-r--r--spec/support/shared_examples/services/pages_size_limit_shared_examples.rb2
-rw-r--r--spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb120
-rw-r--r--spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb38
-rw-r--r--spec/support/snowplow.rb8
-rw-r--r--spec/tasks/gitlab/gitaly_rake_spec.rb2
-rw-r--r--spec/tasks/gitlab/packages/events_rake_spec.rb38
-rw-r--r--spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb42
-rw-r--r--spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb16
-rw-r--r--spec/tooling/lib/tooling/test_file_finder_spec.rb8
-rw-r--r--spec/tooling/lib/tooling/test_map_generator_spec.rb109
-rw-r--r--spec/tooling/lib/tooling/test_map_packer_spec.rb77
-rw-r--r--spec/uploaders/dependency_proxy/file_uploader_spec.rb26
-rw-r--r--spec/uploaders/gitlab_uploader_spec.rb18
-rw-r--r--spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb114
-rw-r--r--spec/validators/rsa_key_validator_spec.rb40
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb16
-rw-r--r--spec/views/profiles/preferences/show.html.haml_spec.rb57
-rw-r--r--spec/views/projects/ci/lints/show.html.haml_spec.rb127
-rw-r--r--spec/views/projects/merge_requests/show.html.haml_spec.rb2
-rw-r--r--spec/views/projects/settings/operations/show.html.haml_spec.rb2
-rw-r--r--spec/views/projects/tags/index.html.haml_spec.rb25
-rw-r--r--spec/views/registrations/welcome/show.html.haml_spec.rb (renamed from spec/views/registrations/welcome.html.haml_spec.rb)2
-rw-r--r--spec/views/search/_filter.html.haml_spec.rb2
-rw-r--r--spec/views/search/_results.html.haml_spec.rb36
-rw-r--r--spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb2
-rw-r--r--spec/workers/background_migration_worker_spec.rb100
-rw-r--r--spec/workers/build_finished_worker_spec.rb42
-rw-r--r--spec/workers/bulk_import_worker_spec.rb16
-rw-r--r--spec/workers/ci/delete_objects_worker_spec.rb20
-rw-r--r--spec/workers/concerns/application_worker_spec.rb4
-rw-r--r--spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb2
-rw-r--r--spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb2
-rw-r--r--spec/workers/concerns/limited_capacity/worker_spec.rb3
-rw-r--r--spec/workers/concerns/reenqueuer_spec.rb15
-rw-r--r--spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb234
-rw-r--r--spec/workers/container_expiration_policy_worker_spec.rb160
-rw-r--r--spec/workers/destroy_pages_deployments_worker_spec.rb38
-rw-r--r--spec/workers/git_garbage_collect_worker_spec.rb14
-rw-r--r--spec/workers/jira_connect/sync_branch_worker_spec.rb33
-rw-r--r--spec/workers/jira_connect/sync_merge_request_worker_spec.rb33
-rw-r--r--spec/workers/jira_connect/sync_project_worker_spec.rb77
-rw-r--r--spec/workers/propagate_integration_inherit_descendant_worker_spec.rb30
-rw-r--r--spec/workers/propagate_integration_inherit_worker_spec.rb2
-rw-r--r--spec/workers/purge_dependency_proxy_cache_worker_spec.rb58
-rw-r--r--spec/workers/remove_expired_members_worker_spec.rb44
-rw-r--r--spec/workers/repository_cleanup_worker_spec.rb10
-rw-r--r--spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb39
1455 files changed, 48287 insertions, 15251 deletions
diff --git a/spec/bin/feature_flag_spec.rb b/spec/bin/feature_flag_spec.rb
index 185a03fc587..710b1606923 100644
--- a/spec/bin/feature_flag_spec.rb
+++ b/spec/bin/feature_flag_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe 'bin/feature-flag' do
let(:options) { FeatureFlagOptionParser.parse(argv) }
let(:creator) { described_class.new(options) }
let(:existing_flags) do
- { 'existing-feature-flag' => File.join('config', 'feature_flags', 'development', 'existing-feature-flag.yml') }
+ { 'existing_feature_flag' => File.join('config', 'feature_flags', 'development', 'existing_feature_flag.yml') }
end
before do
@@ -32,12 +32,12 @@ RSpec.describe 'bin/feature-flag' do
it 'properly creates a feature flag' do
expect(File).to receive(:write).with(
- File.join('config', 'feature_flags', 'development', 'feature-flag-name.yml'),
+ File.join('config', 'feature_flags', 'development', 'feature_flag_name.yml'),
anything)
expect do
subject
- end.to output(/name: feature-flag-name/).to_stdout
+ end.to output(/name: feature_flag_name/).to_stdout
end
context 'when running on master' do
@@ -123,6 +123,29 @@ RSpec.describe 'bin/feature-flag' do
end
end
+ context 'when there is deprecated feature flag type' do
+ before do
+ stub_const('FeatureFlagOptionParser::TYPES',
+ development: { description: 'short' },
+ deprecated: { description: 'deprecated', deprecated: true }
+ )
+ end
+
+ context 'and deprecated type is given' do
+ let(:type) { 'deprecated' }
+
+ it 'shows error message and retries' do
+ expect($stdin).to receive(:gets).and_return(type)
+ expect($stdin).to receive(:gets).and_raise('EOF')
+
+ expect do
+ expect { described_class.read_type }.to raise_error(/EOF/)
+ end.to output(/Specify the feature flag type/).to_stdout
+ .and output(/Invalid type specified/).to_stderr
+ end
+ end
+ end
+
context 'when there are many types defined' do
before do
stub_const('FeatureFlagOptionParser::TYPES',
diff --git a/spec/bin/sidekiq_cluster_spec.rb b/spec/bin/sidekiq_cluster_spec.rb
index fc5e2ae861a..503cc0999c5 100644
--- a/spec/bin/sidekiq_cluster_spec.rb
+++ b/spec/bin/sidekiq_cluster_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe 'bin/sidekiq-cluster' do
context 'when selecting some queues and excluding others' do
where(:args, :included, :excluded) do
%w[--negate cronjob] | '-qdefault,1' | '-qcronjob,1'
+ %w[--queue-selector resource_boundary=cpu] | '-qupdate_merge_requests,1' | '-qdefault,1'
+ # Remove with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
%w[--experimental-queue-selector resource_boundary=cpu] | '-qupdate_merge_requests,1' | '-qdefault,1'
end
@@ -29,6 +31,8 @@ RSpec.describe 'bin/sidekiq-cluster' do
context 'when selecting all queues' do
[
%w[*],
+ %w[--queue-selector *],
+ # Remove with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
%w[--experimental-queue-selector *]
].each do |args|
it "runs successfully with `#{args}`", :aggregate_failures do
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb
index 283d82a3ab8..bfbd2ca946f 100644
--- a/spec/controllers/admin/dashboard_controller_spec.rb
+++ b/spec/controllers/admin/dashboard_controller_spec.rb
@@ -4,12 +4,20 @@ require 'spec_helper'
RSpec.describe Admin::DashboardController do
describe '#index' do
+ before do
+ sign_in(create(:admin))
+ end
+
+ it 'retrieves Redis versions' do
+ get :index
+
+ expect(assigns[:redis_versions].length).to eq(1)
+ end
+
context 'with pending_delete projects' do
render_views
it 'does not retrieve projects that are pending deletion' do
- sign_in(create(:admin))
-
project = create(:project)
pending_delete_project = create(:project, pending_delete: true)
diff --git a/spec/controllers/admin/users_controller_spec.rb b/spec/controllers/admin/users_controller_spec.rb
index 5312a0db7f5..d0d1fa6a6bc 100644
--- a/spec/controllers/admin/users_controller_spec.rb
+++ b/spec/controllers/admin/users_controller_spec.rb
@@ -107,49 +107,40 @@ RSpec.describe Admin::UsersController do
subject { put :approve, params: { id: user.username } }
- context 'when feature is disabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: false)
- end
-
- it 'responds with access denied' do
+ context 'when successful' do
+ it 'activates the user' do
subject
- expect(response).to have_gitlab_http_status(:not_found)
+ user.reload
+
+ expect(user).to be_active
+ expect(flash[:notice]).to eq('Successfully approved')
end
- end
- context 'when feature is enabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: true)
+ it 'emails the user on approval' do
+ expect(DeviseMailer).to receive(:user_admin_approval).with(user).and_call_original
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :user_admin_approval)
end
+ end
- context 'when successful' do
- it 'activates the user' do
- subject
+ context 'when unsuccessful' do
+ let(:user) { create(:user, :blocked) }
- user.reload
+ it 'displays the error' do
+ subject
- expect(user).to be_active
- expect(flash[:notice]).to eq('Successfully approved')
- end
+ expect(flash[:alert]).to eq('The user you are trying to approve is not pending an approval')
end
- context 'when unsuccessful' do
- let(:user) { create(:user, :blocked) }
-
- it 'displays the error' do
- subject
-
- expect(flash[:alert]).to eq('The user you are trying to approve is not pending an approval')
- end
+ it 'does not activate the user' do
+ subject
- it 'does not activate the user' do
- subject
+ user.reload
+ expect(user).not_to be_active
+ end
- user.reload
- expect(user).not_to be_active
- end
+ it 'does not email the pending user' do
+ expect { subject }.not_to have_enqueued_mail(DeviseMailer, :user_admin_approval)
end
end
end
diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb
index d95aac2f386..9342513d224 100644
--- a/spec/controllers/application_controller_spec.rb
+++ b/spec/controllers/application_controller_spec.rb
@@ -171,6 +171,8 @@ RSpec.describe ApplicationController do
describe '#route_not_found' do
controller(described_class) do
+ skip_before_action :authenticate_user!, only: :index
+
def index
route_not_found
end
@@ -184,6 +186,14 @@ RSpec.describe ApplicationController do
expect(response).to have_gitlab_http_status(:not_found)
end
+ it 'renders 404 if client is a search engine crawler' do
+ request.env['HTTP_USER_AGENT'] = 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)'
+
+ get :index
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
it 'redirects to login page if not authenticated' do
get :index
diff --git a/spec/controllers/autocomplete_controller_spec.rb b/spec/controllers/autocomplete_controller_spec.rb
index e7c0bc43e86..c2eb9d54303 100644
--- a/spec/controllers/autocomplete_controller_spec.rb
+++ b/spec/controllers/autocomplete_controller_spec.rb
@@ -382,6 +382,17 @@ RSpec.describe AutocompleteController do
sign_in(user)
end
+ context 'and they cannot read the project' do
+ it 'returns a not found response' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :read_project, project).and_return(false)
+
+ get(:deploy_keys_with_owners, params: { project_id: project.id })
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
it 'renders the deploy key in a json payload, with its owner' do
get(:deploy_keys_with_owners, params: { project_id: project.id })
diff --git a/spec/controllers/concerns/lfs_request_spec.rb b/spec/controllers/concerns/lfs_request_spec.rb
deleted file mode 100644
index 3bafd761a3e..00000000000
--- a/spec/controllers/concerns/lfs_request_spec.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe LfsRequest do
- include ProjectForksHelper
-
- controller(Repositories::GitHttpClientController) do
- # `described_class` is not available in this context
- include LfsRequest
-
- def show
- head :ok
- end
-
- def project
- @project ||= Project.find_by(id: params[:id])
- end
-
- def download_request?
- true
- end
-
- def upload_request?
- false
- end
-
- def ci?
- false
- end
- end
-
- let(:project) { create(:project, :public) }
-
- before do
- stub_lfs_setting(enabled: true)
- end
-
- context 'user is authenticated without access to lfs' do
- before do
- allow(controller).to receive(:authenticate_user)
- allow(controller).to receive(:authentication_result) do
- Gitlab::Auth::Result.new
- end
- end
-
- context 'with access to the project' do
- it 'returns 403' do
- get :show, params: { id: project.id }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'without access to the project' do
- context 'project does not exist' do
- it 'returns 404' do
- get :show, params: { id: 'does not exist' }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'project is private' do
- let(:project) { create(:project, :private) }
-
- it 'returns 404' do
- get :show, params: { id: project.id }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
- end
-end
diff --git a/spec/controllers/concerns/metrics_dashboard_spec.rb b/spec/controllers/concerns/metrics_dashboard_spec.rb
index 8a4d8828aaa..83546403ce5 100644
--- a/spec/controllers/concerns/metrics_dashboard_spec.rb
+++ b/spec/controllers/concerns/metrics_dashboard_spec.rb
@@ -155,6 +155,7 @@ RSpec.describe MetricsDashboard do
'.gitlab/dashboards/errors.yml' => dashboard_yml
}
end
+
let_it_be(:project) { create(:project, :custom_repo, files: dashboards) }
before do
diff --git a/spec/controllers/concerns/send_file_upload_spec.rb b/spec/controllers/concerns/send_file_upload_spec.rb
index 747ccd7ba1b..f9a6afb95ea 100644
--- a/spec/controllers/concerns/send_file_upload_spec.rb
+++ b/spec/controllers/concerns/send_file_upload_spec.rb
@@ -70,61 +70,18 @@ RSpec.describe SendFileUpload do
allow(uploader).to receive(:model).and_return(image_owner)
end
- context 'when boths FFs are enabled' do
- before do
- stub_feature_flags(dynamic_image_resizing_requester: image_requester)
- stub_feature_flags(dynamic_image_resizing_owner: image_owner)
- end
-
- it_behaves_like 'handles image resize requests allowed by FFs'
- end
-
- context 'when boths FFs are enabled globally' do
- before do
- stub_feature_flags(dynamic_image_resizing_requester: true)
- stub_feature_flags(dynamic_image_resizing_owner: true)
- end
-
- it_behaves_like 'handles image resize requests allowed by FFs'
-
- context 'when current_user is nil' do
- before do
- allow(controller).to receive(:current_user).and_return(nil)
- end
-
- it_behaves_like 'handles image resize requests allowed by FFs'
- end
- end
-
- context 'when only FF based on content requester is enabled for current user' do
- before do
- stub_feature_flags(dynamic_image_resizing_requester: image_requester)
- stub_feature_flags(dynamic_image_resizing_owner: false)
- end
-
- it_behaves_like 'bypasses image resize requests not allowed by FFs'
- end
-
- context 'when only FF based on content owner is enabled for requested avatar owner' do
- before do
- stub_feature_flags(dynamic_image_resizing_requester: false)
- stub_feature_flags(dynamic_image_resizing_owner: image_owner)
- end
-
- it_behaves_like 'bypasses image resize requests not allowed by FFs'
- end
+ it_behaves_like 'handles image resize requests allowed by FF'
- context 'when both FFs are disabled' do
+ context 'when FF is disabled' do
before do
- stub_feature_flags(dynamic_image_resizing_requester: false)
- stub_feature_flags(dynamic_image_resizing_owner: false)
+ stub_feature_flags(dynamic_image_resizing: false)
end
- it_behaves_like 'bypasses image resize requests not allowed by FFs'
+ it_behaves_like 'bypasses image resize requests not allowed by FF'
end
end
- shared_examples 'bypasses image resize requests not allowed by FFs' do
+ shared_examples 'bypasses image resize requests not allowed by FF' do
it 'does not write workhorse command header' do
expect(headers).not_to receive(:store).with(Gitlab::Workhorse::SEND_DATA_HEADER, /^send-scaled-img:/)
@@ -132,7 +89,7 @@ RSpec.describe SendFileUpload do
end
end
- shared_examples 'handles image resize requests allowed by FFs' do
+ shared_examples 'handles image resize requests allowed by FF' do
context 'with valid width parameter' do
it 'renders OK with workhorse command header' do
expect(controller).not_to receive(:send_file)
diff --git a/spec/controllers/every_controller_spec.rb b/spec/controllers/every_controller_spec.rb
index b1519c4ef1e..a1c377eff76 100644
--- a/spec/controllers/every_controller_spec.rb
+++ b/spec/controllers/every_controller_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe "Every controller" do
.compact
.select { |route| route[:controller].present? && route[:action].present? }
.map { |route| [constantize_controller(route[:controller]), route[:action]] }
- .select { |(controller, action)| controller&.include?(ControllerWithFeatureCategory) }
+ .select { |(controller, action)| controller&.include?(::Gitlab::WithFeatureCategory) }
.reject { |(controller, action)| controller == ApplicationController || controller == Devise::UnlocksController }
end
diff --git a/spec/controllers/groups/dependency_proxies_controller_spec.rb b/spec/controllers/groups/dependency_proxies_controller_spec.rb
new file mode 100644
index 00000000000..35bd7d47aed
--- /dev/null
+++ b/spec/controllers/groups/dependency_proxies_controller_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::DependencyProxiesController do
+ let(:group) { create(:group) }
+ let(:user) { create(:user) }
+
+ before do
+ group.add_owner(user)
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ context 'feature enabled' do
+ before do
+ enable_dependency_proxy
+ end
+
+ it 'returns 200 and renders the view' do
+ get :show, params: { group_id: group.to_param }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to render_template('groups/dependency_proxies/show')
+ end
+ end
+
+ it 'returns 404 when feature is disabled' do
+ disable_dependency_proxy
+
+ get :show, params: { group_id: group.to_param }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ describe 'PUT #update' do
+ context 'feature enabled' do
+ before do
+ enable_dependency_proxy
+ end
+
+ it 'redirects back to show page' do
+ put :update, params: update_params
+
+ expect(response).to have_gitlab_http_status(:found)
+ end
+ end
+
+ it 'returns 404 when feature is disabled' do
+ put :update, params: update_params
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ def update_params
+ {
+ group_id: group.to_param,
+ dependency_proxy_group_setting: { enabled: true }
+ }
+ end
+ end
+
+ def enable_dependency_proxy
+ stub_config(dependency_proxy: { enabled: true })
+
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ def disable_dependency_proxy
+ group.create_dependency_proxy_setting!(enabled: false)
+ end
+end
diff --git a/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
new file mode 100644
index 00000000000..615b56ff22f
--- /dev/null
+++ b/spec/controllers/groups/dependency_proxy_for_containers_controller_spec.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Groups::DependencyProxyForContainersController do
+ let(:group) { create(:group) }
+ let(:token_response) { { status: :success, token: 'abcd1234' } }
+
+ shared_examples 'not found when disabled' do
+ context 'feature disabled' do
+ before do
+ disable_dependency_proxy
+ end
+
+ it 'returns 404' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ before do
+ allow(Gitlab.config.dependency_proxy)
+ .to receive(:enabled).and_return(true)
+
+ allow_next_instance_of(DependencyProxy::RequestTokenService) do |instance|
+ allow(instance).to receive(:execute).and_return(token_response)
+ end
+ end
+
+ describe 'GET #manifest' do
+ let(:manifest) { { foo: 'bar' }.to_json }
+ let(:pull_response) { { status: :success, manifest: manifest } }
+
+ before do
+ allow_next_instance_of(DependencyProxy::PullManifestService) do |instance|
+ allow(instance).to receive(:execute).and_return(pull_response)
+ end
+ end
+
+ subject { get_manifest }
+
+ context 'feature enabled' do
+ before do
+ enable_dependency_proxy
+ end
+
+ context 'remote token request fails' do
+ let(:token_response) do
+ {
+ status: :error,
+ http_status: 503,
+ message: 'Service Unavailable'
+ }
+ end
+
+ it 'proxies status from the remote token request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:service_unavailable)
+ expect(response.body).to eq('Service Unavailable')
+ end
+ end
+
+ context 'remote manifest request fails' do
+ let(:pull_response) do
+ {
+ status: :error,
+ http_status: 400,
+ message: ''
+ }
+ end
+
+ it 'proxies status from the remote manifest request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.body).to be_empty
+ end
+ end
+
+ it 'returns 200 with manifest file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.body).to eq(manifest)
+ end
+ end
+
+ it_behaves_like 'not found when disabled'
+
+ def get_manifest
+ get :manifest, params: { group_id: group.to_param, image: 'alpine', tag: '3.9.2' }
+ end
+ end
+
+ describe 'GET #blob' do
+ let(:blob) { create(:dependency_proxy_blob) }
+ let(:blob_sha) { blob.file_name.sub('.gz', '') }
+ let(:blob_response) { { status: :success, blob: blob } }
+
+ before do
+ allow_next_instance_of(DependencyProxy::FindOrCreateBlobService) do |instance|
+ allow(instance).to receive(:execute).and_return(blob_response)
+ end
+ end
+
+ subject { get_blob }
+
+ context 'feature enabled' do
+ before do
+ enable_dependency_proxy
+ end
+
+ context 'remote blob request fails' do
+ let(:blob_response) do
+ {
+ status: :error,
+ http_status: 400,
+ message: ''
+ }
+ end
+
+ it 'proxies status from the remote blob request' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(response.body).to be_empty
+ end
+ end
+
+ it 'sends a file' do
+ expect(controller).to receive(:send_file).with(blob.file.path, {})
+
+ subject
+ end
+
+ it 'returns Content-Disposition: attachment' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers['Content-Disposition']).to match(/^attachment/)
+ end
+ end
+
+ it_behaves_like 'not found when disabled'
+
+ def get_blob
+ get :blob, params: { group_id: group.to_param, image: 'alpine', sha: blob_sha }
+ end
+ end
+
+ def enable_dependency_proxy
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ def disable_dependency_proxy
+ group.create_dependency_proxy_setting!(enabled: false)
+ end
+end
diff --git a/spec/controllers/groups/registry/repositories_controller_spec.rb b/spec/controllers/groups/registry/repositories_controller_spec.rb
index ae982b02a4f..70125087f30 100644
--- a/spec/controllers/groups/registry/repositories_controller_spec.rb
+++ b/spec/controllers/groups/registry/repositories_controller_spec.rb
@@ -64,12 +64,11 @@ RSpec.describe Groups::Registry::RepositoriesController do
context 'html format' do
let(:format) { :html }
- it 'show index page' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
+ it 'show index page', :snowplow do
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect_no_snowplow_event
end
end
diff --git a/spec/controllers/groups/settings/integrations_controller_spec.rb b/spec/controllers/groups/settings/integrations_controller_spec.rb
index cdcdfde175f..beb2ad3afec 100644
--- a/spec/controllers/groups/settings/integrations_controller_spec.rb
+++ b/spec/controllers/groups/settings/integrations_controller_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe Groups::Settings::IntegrationsController do
describe '#edit' do
context 'when user is not owner' do
it 'renders not_found' do
- get :edit, params: { group_id: group, id: Service.available_services_names.sample }
+ get :edit, params: { group_id: group, id: Service.available_services_names(include_project_specific: false).sample }
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -61,13 +61,13 @@ RSpec.describe Groups::Settings::IntegrationsController do
it 'returns not_found' do
stub_feature_flags(group_level_integrations: false)
- get :edit, params: { group_id: group, id: Service.available_services_names.sample }
+ get :edit, params: { group_id: group, id: Service.available_services_names(include_project_specific: false).sample }
expect(response).to have_gitlab_http_status(:not_found)
end
end
- Service.available_services_names.each do |integration_name|
+ Service.available_services_names(include_project_specific: false).each do |integration_name|
context "#{integration_name}" do
it 'successfully displays the template' do
get :edit, params: { group_id: group, id: integration_name }
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index df7e018b35e..55833ee3aad 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -319,10 +319,10 @@ RSpec.describe GroupsController, factory_default: :keep do
stub_experiment(onboarding_issues: false)
end
- it 'does not track anything' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
+ it 'does not track anything', :snowplow do
create_namespace
+
+ expect_no_snowplow_event
end
end
@@ -336,15 +336,15 @@ RSpec.describe GroupsController, factory_default: :keep do
stub_experiment_for_user(onboarding_issues: false)
end
- it 'tracks the event with the "created_namespace" action with the "control_group" property' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Conversion::Experiment::OnboardingIssues',
- 'created_namespace',
+ it 'tracks the event with the "created_namespace" action with the "control_group" property', :snowplow do
+ create_namespace
+
+ expect_snowplow_event(
+ category: 'Growth::Conversion::Experiment::OnboardingIssues',
+ action: 'created_namespace',
label: anything,
property: 'control_group'
)
-
- create_namespace
end
end
@@ -353,15 +353,15 @@ RSpec.describe GroupsController, factory_default: :keep do
stub_experiment_for_user(onboarding_issues: true)
end
- it 'tracks the event with the "created_namespace" action with the "experimental_group" property' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Conversion::Experiment::OnboardingIssues',
- 'created_namespace',
+ it 'tracks the event with the "created_namespace" action with the "experimental_group" property', :snowplow do
+ create_namespace
+
+ expect_snowplow_event(
+ category: 'Growth::Conversion::Experiment::OnboardingIssues',
+ action: 'created_namespace',
label: anything,
property: 'experimental_group'
)
-
- create_namespace
end
end
end
@@ -1213,4 +1213,60 @@ RSpec.describe GroupsController, factory_default: :keep do
it_behaves_like 'disabled when using an external authorization service'
end
end
+
+ describe 'GET #unfoldered_environment_names' do
+ it 'shows the environment names of a public project to an anonymous user' do
+ public_project = create(:project, :public, namespace: group)
+
+ create(:environment, project: public_project, name: 'foo')
+
+ get(
+ :unfoldered_environment_names,
+ params: { id: group, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(%w[foo])
+ end
+
+ it 'does not show environment names of private projects to anonymous users' do
+ create(:environment, project: project, name: 'foo')
+
+ get(
+ :unfoldered_environment_names,
+ params: { id: group, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ end
+
+ it 'shows environment names of a private project to a group member' do
+ create(:environment, project: project, name: 'foo')
+ sign_in(developer)
+
+ get(
+ :unfoldered_environment_names,
+ params: { id: group, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(%w[foo])
+ end
+
+ it 'does not show environment names of private projects to a logged-in non-member' do
+ alice = create(:user)
+
+ create(:environment, project: project, name: 'foo')
+ sign_in(alice)
+
+ get(
+ :unfoldered_environment_names,
+ params: { id: group, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_empty
+ end
+ end
end
diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb
index f3850ff844e..dd850a86227 100644
--- a/spec/controllers/import/bulk_imports_controller_spec.rb
+++ b/spec/controllers/import/bulk_imports_controller_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Import::BulkImportsController do
expect(session[:bulk_import_gitlab_url]).to be_nil
expect(response).to have_gitlab_http_status(:found)
- expect(response).to redirect_to(status_import_bulk_import_url)
+ expect(response).to redirect_to(status_import_bulk_imports_url)
end
end
@@ -37,7 +37,7 @@ RSpec.describe Import::BulkImportsController do
expect(session[:bulk_import_gitlab_access_token]).to eq(token)
expect(session[:bulk_import_gitlab_url]).to eq(url)
expect(response).to have_gitlab_http_status(:found)
- expect(response).to redirect_to(status_import_bulk_import_url)
+ expect(response).to redirect_to(status_import_bulk_imports_url)
end
it 'strips access token with spaces' do
@@ -46,19 +46,21 @@ RSpec.describe Import::BulkImportsController do
post :configure, params: { bulk_import_gitlab_access_token: " #{token} " }
expect(session[:bulk_import_gitlab_access_token]).to eq(token)
- expect(controller).to redirect_to(status_import_bulk_import_url)
+ expect(controller).to redirect_to(status_import_bulk_imports_url)
end
end
describe 'GET status' do
- let(:client) { Gitlab::BulkImport::Client.new(uri: 'http://gitlab.example', token: 'token') }
+ let(:client) { BulkImports::Clients::Http.new(uri: 'http://gitlab.example', token: 'token') }
describe 'serialized group data' do
let(:client_response) do
- [
- { 'id' => 1, 'full_name' => 'group1', 'full_path' => 'full/path/group1' },
- { 'id' => 2, 'full_name' => 'group2', 'full_path' => 'full/path/group2' }
- ]
+ double(
+ parsed_response: [
+ { 'id' => 1, 'full_name' => 'group1', 'full_path' => 'full/path/group1' },
+ { 'id' => 2, 'full_name' => 'group2', 'full_path' => 'full/path/group2' }
+ ]
+ )
end
before do
@@ -69,7 +71,7 @@ RSpec.describe Import::BulkImportsController do
it 'returns serialized group data' do
get :status, format: :json
- expect(response.parsed_body).to eq({ importable_data: client_response }.as_json)
+ expect(json_response).to eq({ importable_data: client_response.parsed_response }.as_json)
end
end
@@ -111,7 +113,7 @@ RSpec.describe Import::BulkImportsController do
context 'when connection error occurs' do
before do
allow(controller).to receive(:client).and_return(client)
- allow(client).to receive(:get).and_raise(Gitlab::BulkImport::Client::ConnectionError)
+ allow(client).to receive(:get).and_raise(BulkImports::Clients::Http::ConnectionError)
end
it 'returns 422' do
@@ -128,9 +130,21 @@ RSpec.describe Import::BulkImportsController do
end
end
end
+
+ describe 'POST create' do
+ it 'executes BulkImportService' do
+ expect_next_instance_of(BulkImportService) do |service|
+ expect(service).to receive(:execute)
+ end
+
+ post :create
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
end
- context 'when gitlab_api_imports feature flag is disabled' do
+ context 'when bulk_import feature flag is disabled' do
before do
stub_feature_flags(bulk_import: false)
end
diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb
index e19b6caca5b..a408d821833 100644
--- a/spec/controllers/import/github_controller_spec.rb
+++ b/spec/controllers/import/github_controller_spec.rb
@@ -144,6 +144,58 @@ RSpec.describe Import::GithubController do
expect(json_response.dig('provider_repos', 0, 'id')).to eq(repo_1.id)
expect(json_response.dig('provider_repos', 1, 'id')).to eq(repo_2.id)
end
+
+ context 'when filtering' do
+ let(:filter) { 'test' }
+ let(:user_login) { 'user' }
+ let(:collaborations_subquery) { 'repo:repo1 repo:repo2' }
+ let(:organizations_subquery) { 'org:org1 org:org2' }
+
+ before do
+ allow_next_instance_of(Octokit::Client) do |client|
+ allow(client).to receive(:user).and_return(double(login: user_login))
+ end
+ end
+
+ it 'makes request to github search api' do
+ expected_query = "test in:name is:public,private user:#{user_login} #{collaborations_subquery} #{organizations_subquery}"
+
+ expect_next_instance_of(Gitlab::GithubImport::Client) do |client|
+ expect(client).to receive(:collaborations_subquery).and_return(collaborations_subquery)
+ expect(client).to receive(:organizations_subquery).and_return(organizations_subquery)
+ expect(client).to receive(:each_page).with(:search_repositories, expected_query).and_return([].to_enum)
+ end
+
+ get :status, params: { filter: filter }, format: :json
+ end
+
+ context 'when user input contains colons and spaces' do
+ before do
+ stub_client(search_repos_by_name: [])
+ end
+
+ it 'sanitizes user input' do
+ filter = ' test1:test2 test3 : test4 '
+ expected_filter = 'test1test2test3test4'
+
+ get :status, params: { filter: filter }, format: :json
+
+ expect(assigns(:filter)).to eq(expected_filter)
+ end
+ end
+
+ context 'when rate limit threshold is exceeded' do
+ before do
+ allow(controller).to receive(:status).and_raise(Gitlab::GithubImport::RateLimitError)
+ end
+
+ it 'returns 429' do
+ get :status, params: { filter: 'test' }, format: :json
+
+ expect(response).to have_gitlab_http_status(:too_many_requests)
+ end
+ end
+ end
end
end
diff --git a/spec/controllers/invites_controller_spec.rb b/spec/controllers/invites_controller_spec.rb
index 75a972d2f95..2d13b942c31 100644
--- a/spec/controllers/invites_controller_spec.rb
+++ b/spec/controllers/invites_controller_spec.rb
@@ -9,13 +9,6 @@ RSpec.describe InvitesController, :snowplow do
let(:project_members) { member.source.users }
let(:md5_member_global_id) { Digest::MD5.hexdigest(member.to_global_id.to_s) }
let(:params) { { id: raw_invite_token } }
- let(:snowplow_event) do
- {
- category: 'Growth::Acquisition::Experiment::InviteEmail',
- label: md5_member_global_id,
- property: group_type
- }
- end
shared_examples 'invalid token' do
context 'when invite token is not valid' do
@@ -94,38 +87,6 @@ RSpec.describe InvitesController, :snowplow do
expect(flash[:notice]).to be_nil
end
- context 'when new_user_invite is not set' do
- it 'does not track the user as experiment group' do
- request
-
- expect_no_snowplow_event
- end
- end
-
- context 'when new_user_invite is experiment' do
- let(:params) { { id: raw_invite_token, new_user_invite: 'experiment' } }
- let(:group_type) { 'experiment_group' }
-
- it 'tracks the user as experiment group' do
- request
-
- expect_snowplow_event(**snowplow_event.merge(action: 'opened'))
- expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
- end
- end
-
- context 'when new_user_invite is control' do
- let(:params) { { id: raw_invite_token, new_user_invite: 'control' } }
- let(:group_type) { 'control_group' }
-
- it 'tracks the user as control group' do
- request
-
- expect_snowplow_event(**snowplow_event.merge(action: 'opened'))
- expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
- end
- end
-
it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
it_behaves_like 'invalid token'
end
@@ -158,36 +119,6 @@ RSpec.describe InvitesController, :snowplow do
subject(:request) { post :accept, params: params }
- context 'when new_user_invite is not set' do
- it 'does not track an event' do
- request
-
- expect_no_snowplow_event
- end
- end
-
- context 'when new_user_invite is experiment' do
- let(:params) { { id: raw_invite_token, new_user_invite: 'experiment' } }
- let(:group_type) { 'experiment_group' }
-
- it 'tracks the user as experiment group' do
- request
-
- expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
- end
- end
-
- context 'when new_user_invite is control' do
- let(:params) { { id: raw_invite_token, new_user_invite: 'control' } }
- let(:group_type) { 'control_group' }
-
- it 'tracks the user as control group' do
- request
-
- expect_snowplow_event(**snowplow_event.merge(action: 'accepted'))
- end
- end
-
it_behaves_like "tracks the 'accepted' event for the invitation reminders experiment"
it_behaves_like 'invalid token'
end
diff --git a/spec/controllers/jwks_controller_spec.rb b/spec/controllers/jwks_controller_spec.rb
new file mode 100644
index 00000000000..013ec01eba2
--- /dev/null
+++ b/spec/controllers/jwks_controller_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JwksController do
+ describe 'GET #index' do
+ let(:ci_jwt_signing_key) { OpenSSL::PKey::RSA.generate(1024) }
+ let(:ci_jwk) { ci_jwt_signing_key.to_jwk }
+ let(:oidc_jwk) { OpenSSL::PKey::RSA.new(Rails.application.secrets.openid_connect_signing_key).to_jwk }
+
+ before do
+ stub_application_setting(ci_jwt_signing_key: ci_jwt_signing_key.to_s)
+ end
+
+ it 'returns signing keys used to sign CI_JOB_JWT' do
+ get :index
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ ids = json_response['keys'].map { |jwk| jwk['kid'] }
+ expect(ids).to contain_exactly(ci_jwk['kid'], oidc_jwk['kid'])
+ end
+
+ it 'does not leak private key data' do
+ get :index
+
+ aggregate_failures do
+ json_response['keys'].each do |jwk|
+ expect(jwk.keys).to contain_exactly('kty', 'kid', 'e', 'n', 'use', 'alg')
+ expect(jwk['use']).to eq('sig')
+ expect(jwk['alg']).to eq('RS256')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb
index 249e6322d1c..7a72a13febe 100644
--- a/spec/controllers/profiles_controller_spec.rb
+++ b/spec/controllers/profiles_controller_spec.rb
@@ -84,9 +84,10 @@ RSpec.describe ProfilesController, :request_store do
it 'allows setting a user status' do
sign_in(user)
- put :update, params: { user: { status: { message: 'Working hard!' } } }
+ put :update, params: { user: { status: { message: 'Working hard!', availability: 'busy' } } }
expect(user.reload.status.message).to eq('Working hard!')
+ expect(user.reload.status.availability).to eq('busy')
expect(response).to have_gitlab_http_status(:found)
end
diff --git a/spec/controllers/projects/alerting/notifications_controller_spec.rb b/spec/controllers/projects/alerting/notifications_controller_spec.rb
index 33fd73c762a..b3d37723ccf 100644
--- a/spec/controllers/projects/alerting/notifications_controller_spec.rb
+++ b/spec/controllers/projects/alerting/notifications_controller_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Projects::Alerting::NotificationsController do
let_it_be(:project) { create(:project) }
let_it_be(:environment) { create(:environment, project: project) }
+ let(:params) { project_params }
describe 'POST #create' do
around do |example|
@@ -20,7 +21,7 @@ RSpec.describe Projects::Alerting::NotificationsController do
end
def make_request
- post :create, params: project_params, body: payload.to_json, as: :json
+ post :create, params: params, body: payload.to_json, as: :json
end
context 'when notification service succeeds' do
@@ -53,26 +54,69 @@ RSpec.describe Projects::Alerting::NotificationsController do
context 'bearer token' do
context 'when set' do
- it 'extracts bearer token' do
- request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token'
+ context 'when extractable' do
+ before do
+ request.headers['HTTP_AUTHORIZATION'] = 'Bearer some token'
+ end
- expect(notify_service).to receive(:execute).with('some token')
+ it 'extracts bearer token' do
+ expect(notify_service).to receive(:execute).with('some token', nil)
- make_request
+ make_request
+ end
+
+ context 'with a corresponding integration' do
+ context 'with integration parameters specified' do
+ let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project) }
+ let(:params) { project_params(endpoint_identifier: integration.endpoint_identifier, name: integration.name) }
+
+ context 'the integration is active' do
+ it 'extracts and finds the integration' do
+ expect(notify_service).to receive(:execute).with('some token', integration)
+
+ make_request
+ end
+ end
+
+ context 'when the integration is inactive' do
+ before do
+ integration.update!(active: false)
+ end
+
+ it 'does not find an integration' do
+ expect(notify_service).to receive(:execute).with('some token', nil)
+
+ make_request
+ end
+ end
+ end
+
+ context 'without integration parameters specified' do
+ let_it_be(:integration) { create(:alert_management_http_integration, :legacy, project: project) }
+
+ it 'extracts and finds the legacy integration' do
+ expect(notify_service).to receive(:execute).with('some token', integration)
+
+ make_request
+ end
+ end
+ end
end
- it 'pass nil if cannot extract a non-bearer token' do
- request.headers['HTTP_AUTHORIZATION'] = 'some token'
+ context 'when inextractable' do
+ it 'passes nil for a non-bearer token' do
+ request.headers['HTTP_AUTHORIZATION'] = 'some token'
- expect(notify_service).to receive(:execute).with(nil)
+ expect(notify_service).to receive(:execute).with(nil, nil)
- make_request
+ make_request
+ end
end
end
context 'when missing' do
it 'passes nil' do
- expect(notify_service).to receive(:execute).with(nil)
+ expect(notify_service).to receive(:execute).with(nil, nil)
make_request
end
diff --git a/spec/controllers/projects/avatars_controller_spec.rb b/spec/controllers/projects/avatars_controller_spec.rb
index 16e9c845307..35878fe4c2d 100644
--- a/spec/controllers/projects/avatars_controller_spec.rb
+++ b/spec/controllers/projects/avatars_controller_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe Projects::AvatarsController do
- let_it_be(:project) { create(:project, :repository) }
+ describe 'GET #show' do
+ let_it_be(:project) { create(:project, :public, :repository) }
- before do
- controller.instance_variable_set(:@project, project)
- end
+ before do
+ controller.instance_variable_set(:@project, project)
+ end
- describe 'GET #show' do
- subject { get :show, params: { namespace_id: project.namespace, project_id: project } }
+ subject { get :show, params: { namespace_id: project.namespace, project_id: project.path } }
context 'when repository has no avatar' do
it 'shows 404' do
@@ -37,6 +37,15 @@ RSpec.describe Projects::AvatarsController do
expect(response.header[Gitlab::Workhorse::DETECT_HEADER]).to eq 'true'
end
+ it 'sets appropriate caching headers' do
+ sign_in(project.owner)
+ subject
+
+ expect(response.cache_control[:public]).to eq(true)
+ expect(response.cache_control[:max_age]).to eq(60)
+ expect(response.cache_control[:no_store]).to be_nil
+ end
+
it_behaves_like 'project cache control headers'
end
@@ -51,9 +60,16 @@ RSpec.describe Projects::AvatarsController do
end
describe 'DELETE #destroy' do
+ let(:project) { create(:project, :repository, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) }
+
+ before do
+ sign_in(project.owner)
+ end
+
it 'removes avatar from DB by calling destroy' do
- delete :destroy, params: { namespace_id: project.namespace.id, project_id: project.id }
+ delete :destroy, params: { namespace_id: project.namespace.path, project_id: project.path }
+ expect(response).to redirect_to(edit_project_path(project, anchor: 'js-general-project-settings'))
expect(project.avatar.present?).to be_falsey
expect(project).to be_valid
end
diff --git a/spec/controllers/projects/ci/lints_controller_spec.rb b/spec/controllers/projects/ci/lints_controller_spec.rb
index 22f052e39b7..c4e040b0287 100644
--- a/spec/controllers/projects/ci/lints_controller_spec.rb
+++ b/spec/controllers/projects/ci/lints_controller_spec.rb
@@ -47,9 +47,9 @@ RSpec.describe Projects::Ci::LintsController do
describe 'POST #create' do
subject { post :create, params: params }
- let(:format) { :html }
- let(:params) { { namespace_id: project.namespace, project_id: project, content: content, format: format } }
+ let(:params) { { namespace_id: project.namespace, project_id: project, content: content, format: :json } }
let(:remote_file_path) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
+ let(:parsed_body) { Gitlab::Json.parse(response.body) }
let(:remote_file_content) do
<<~HEREDOC
@@ -72,17 +72,23 @@ RSpec.describe Projects::Ci::LintsController do
HEREDOC
end
- shared_examples 'successful request with format json' do
- context 'with format json' do
- let(:format) { :json }
- let(:parsed_body) { Gitlab::Json.parse(response.body) }
+ shared_examples 'returns a successful validation' do
+ before do
+ subject
+ end
- it 'renders json' do
- expect(response).to have_gitlab_http_status :ok
- expect(response.content_type).to eq 'application/json'
- expect(parsed_body).to include('errors', 'warnings', 'jobs', 'valid')
- expect(parsed_body).to match_schema('entities/lint_result_entity')
- end
+ it 'returns successfully' do
+ expect(response).to have_gitlab_http_status :ok
+ end
+
+ it 'renders json' do
+ expect(response.content_type).to eq 'application/json'
+ expect(parsed_body).to include('errors', 'warnings', 'jobs', 'valid')
+ expect(parsed_body).to match_schema('entities/lint_result_entity')
+ end
+
+ it 'retrieves project' do
+ expect(assigns(:project)).to eq(project)
end
end
@@ -92,25 +98,7 @@ RSpec.describe Projects::Ci::LintsController do
project.add_developer(user)
end
- shared_examples 'returns a successful validation' do
- before do
- subject
- end
-
- it 'returns successfully' do
- expect(response).to have_gitlab_http_status :ok
- end
-
- it 'renders show page' do
- expect(response).to render_template :show
- end
-
- it 'retrieves project' do
- expect(assigns(:project)).to eq(project)
- end
-
- it_behaves_like 'successful request with format json'
- end
+ it_behaves_like 'returns a successful validation'
context 'using legacy validation (YamlProcessor)' do
it_behaves_like 'returns a successful validation'
@@ -135,20 +123,6 @@ RSpec.describe Projects::Ci::LintsController do
subject
end
-
- context 'when dry_run feature flag is disabled' do
- before do
- stub_feature_flags(ci_lint_creates_pipeline_with_dry_run: false)
- end
-
- it_behaves_like 'returns a successful validation'
-
- it 'runs validations through YamlProcessor' do
- expect(Gitlab::Ci::YamlProcessor).to receive(:new).and_call_original
-
- subject
- end
- end
end
end
@@ -166,27 +140,23 @@ RSpec.describe Projects::Ci::LintsController do
subject
end
+ it_behaves_like 'returns a successful validation'
+
it 'assigns result with errors' do
- expect(assigns[:result].errors).to match_array([
+ expect(parsed_body['errors']).to match_array([
'jobs rubocop config should implement a script: or a trigger: keyword',
'jobs config should contain at least one visible job'
])
end
- it 'render show page' do
- expect(response).to render_template :show
- end
-
- it_behaves_like 'successful request with format json'
-
context 'with dry_run mode' do
subject { post :create, params: params.merge(dry_run: 'true') }
it 'assigns result with errors' do
- expect(assigns[:result].errors).to eq(['jobs rubocop config should implement a script: or a trigger: keyword'])
+ expect(parsed_body['errors']).to eq(['jobs rubocop config should implement a script: or a trigger: keyword'])
end
- it_behaves_like 'successful request with format json'
+ it_behaves_like 'returns a successful validation'
end
end
@@ -200,14 +170,6 @@ RSpec.describe Projects::Ci::LintsController do
it 'responds with 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
-
- context 'with format json' do
- let(:format) { :json }
-
- it 'responds with 404' do
- expect(response).to have_gitlab_http_status :not_found
- end
- end
end
end
end
diff --git a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb
new file mode 100644
index 00000000000..1bf6ff95c44
--- /dev/null
+++ b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::Ci::PipelineEditorController do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ describe 'GET #show' do
+ context 'with enough privileges' do
+ before do
+ project.add_developer(user)
+
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
+
+ it { expect(response).to have_gitlab_http_status(:ok) }
+
+ it 'renders show page' do
+ expect(response).to render_template :show
+ end
+ end
+
+ context 'without enough privileges' do
+ before do
+ project.add_reporter(user)
+
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
+
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when ci_pipeline_editor_page feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_pipeline_editor_page: false)
+ project.add_developer(user)
+
+ get :show, params: { namespace_id: project.namespace, project_id: project }
+ end
+
+ it 'responds with 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
index c5b72ff2b3b..f940da7ea35 100644
--- a/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics/events_controller_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Projects::CycleAnalytics::EventsController do
project.add_maintainer(user)
end
- describe 'cycle analytics not set up flag' do
+ describe 'value stream analytics not set up flag' do
context 'with no data' do
it 'is empty' do
get_issue
diff --git a/spec/controllers/projects/cycle_analytics_controller_spec.rb b/spec/controllers/projects/cycle_analytics_controller_spec.rb
index e956065972f..24c2d568d9a 100644
--- a/spec/controllers/projects/cycle_analytics_controller_spec.rb
+++ b/spec/controllers/projects/cycle_analytics_controller_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Projects::CycleAnalyticsController do
end
end
- describe 'cycle analytics not set up flag' do
+ describe 'value stream analytics not set up flag' do
context 'with no data' do
it 'is true' do
get(:show,
diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb
index 029b4210f19..5e09a50aa36 100644
--- a/spec/controllers/projects/imports_controller_spec.rb
+++ b/spec/controllers/projects/imports_controller_spec.rb
@@ -7,10 +7,21 @@ RSpec.describe Projects::ImportsController do
let(:project) { create(:project) }
before do
- sign_in(user)
+ sign_in(user) if user
end
describe 'GET #show' do
+ context 'when user is not authenticated and the project is public' do
+ let(:user) { nil }
+ let(:project) { create(:project, :public) }
+
+ it 'returns 404 response' do
+ get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
context 'when the user has maintainer rights' do
before do
project.add_maintainer(user)
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index f956baa0e22..26e1842468b 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -52,14 +52,14 @@ RSpec.describe Projects::IssuesController do
get :index, params: { namespace_id: project.namespace, project_id: project }
expect(response).to redirect_to(project_issues_path(new_project))
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:moved_permanently)
end
it 'redirects from an old issue correctly' do
get :show, params: { namespace_id: project.namespace, project_id: project, id: issue }
expect(response).to redirect_to(project_issue_path(new_project, issue))
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:moved_permanently)
end
end
end
@@ -1869,7 +1869,7 @@ RSpec.describe Projects::IssuesController do
}
expect(response).to redirect_to(designs_project_issue_path(new_project, issue))
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:moved_permanently)
end
end
end
diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
index 91770a00081..bda1f1a3b1c 100644
--- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb
@@ -383,6 +383,7 @@ RSpec.describe Projects::MergeRequests::DiffsController do
environment: nil,
merge_request: merge_request,
diff_view: :inline,
+ merge_ref_head_diff: nil,
pagination_data: {
current_page: nil,
next_page: nil,
@@ -454,6 +455,31 @@ RSpec.describe Projects::MergeRequests::DiffsController do
it_behaves_like 'successful request'
end
+ context 'with paths param' do
+ let(:example_file_path) { "README" }
+ let(:file_path_option) { { paths: [example_file_path] } }
+
+ subject do
+ go(file_path_option)
+ end
+
+ it_behaves_like 'serializes diffs with expected arguments' do
+ let(:collection) { Gitlab::Diff::FileCollection::MergeRequestDiffBatch }
+ let(:expected_options) do
+ collection_arguments(current_page: 1, total_pages: 1)
+ end
+ end
+
+ it_behaves_like 'successful request'
+
+ it 'filters down the response to the expected file path' do
+ subject
+
+ expect(json_response["diff_files"].size).to eq(1)
+ expect(json_response["diff_files"].first["file_path"]).to eq(example_file_path)
+ end
+ end
+
context 'with default params' do
subject { go }
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index ee194e5ff2f..f159f0e6099 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -6,14 +6,10 @@ RSpec.describe Projects::MergeRequestsController do
include ProjectForksHelper
include Gitlab::Routing
- let(:project) { create(:project, :repository) }
- let(:user) { project.owner }
+ let_it_be_with_refind(:project) { create(:project, :repository) }
+ let_it_be_with_reload(:project_public_with_private_builds) { create(:project, :repository, :public, :builds_private) }
+ let(:user) { project.owner }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
- let(:merge_request_with_conflicts) do
- create(:merge_request, source_branch: 'conflict-resolvable', target_branch: 'conflict-start', source_project: project, merge_status: :unchecked) do |mr|
- mr.mark_as_unmergeable
- end
- end
before do
sign_in(user)
@@ -99,7 +95,8 @@ RSpec.describe Projects::MergeRequestsController do
project,
merge_request,
'json',
- diff_head: true))
+ diff_head: true,
+ view: 'inline'))
end
end
@@ -107,7 +104,7 @@ RSpec.describe Projects::MergeRequestsController do
render_views
it 'renders merge request page' do
- merge_request.merge_request_diff.destroy
+ merge_request.merge_request_diff.destroy!
go(format: :html)
@@ -147,7 +144,7 @@ RSpec.describe Projects::MergeRequestsController do
let(:new_project) { create(:project) }
before do
- project.route.destroy
+ project.route.destroy!
new_project.redirect_routes.create!(path: project.full_path)
new_project.add_developer(user)
end
@@ -161,7 +158,7 @@ RSpec.describe Projects::MergeRequestsController do
}
expect(response).to redirect_to(project_merge_request_path(new_project, merge_request))
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:moved_permanently)
end
it 'redirects from an old merge request commits correctly' do
@@ -173,7 +170,7 @@ RSpec.describe Projects::MergeRequestsController do
}
expect(response).to redirect_to(commits_project_merge_request_path(new_project, merge_request))
- expect(response).to have_gitlab_http_status(:found)
+ expect(response).to have_gitlab_http_status(:moved_permanently)
end
end
end
@@ -359,12 +356,11 @@ RSpec.describe Projects::MergeRequestsController do
end
context 'there is no source project' do
- let(:project) { create(:project, :repository) }
let(:forked_project) { fork_project_with_submodules(project) }
let!(:merge_request) { create(:merge_request, source_project: forked_project, source_branch: 'add-submodule-version-bump', target_branch: 'master', target_project: project) }
before do
- forked_project.destroy
+ forked_project.destroy!
end
it 'closes MR without errors' do
@@ -435,7 +431,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'when the merge request is not mergeable' do
before do
- merge_request.update(title: "WIP: #{merge_request.title}")
+ merge_request.update!(title: "WIP: #{merge_request.title}")
post :merge, params: base_params
end
@@ -475,7 +471,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'when squash is passed as 1' do
it 'updates the squash attribute on the MR to true' do
- merge_request.update(squash: false)
+ merge_request.update!(squash: false)
merge_with_sha(squash: '1')
expect(merge_request.reload.squash_on_merge?).to be_truthy
@@ -484,7 +480,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'when squash is passed as 0' do
it 'updates the squash attribute on the MR to false' do
- merge_request.update(squash: true)
+ merge_request.update!(squash: true)
merge_with_sha(squash: '0')
expect(merge_request.reload.squash_on_merge?).to be_falsey
@@ -547,7 +543,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'and head pipeline is not the current one' do
before do
- head_pipeline.update(sha: 'not_current_sha')
+ head_pipeline.update!(sha: 'not_current_sha')
end
it 'returns :failed' do
@@ -667,9 +663,9 @@ RSpec.describe Projects::MergeRequestsController do
end
context "when the user is owner" do
- let(:owner) { create(:user) }
- let(:namespace) { create(:namespace, owner: owner) }
- let(:project) { create(:project, :repository, namespace: namespace) }
+ let_it_be(:owner) { create(:user) }
+ let_it_be(:namespace) { create(:namespace, owner: owner) }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace) }
before do
sign_in owner
@@ -765,7 +761,7 @@ RSpec.describe Projects::MergeRequestsController do
end
context 'with private builds on a public project' do
- let(:project) { create(:project, :repository, :public, :builds_private) }
+ let(:project) { project_public_with_private_builds }
context 'for a project owner' do
it 'responds with serialized pipelines' do
@@ -813,7 +809,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'with public builds' do
let(:forked_project) do
fork_project(project, fork_user, repository: true).tap do |new_project|
- new_project.project_feature.update(builds_access_level: ProjectFeature::ENABLED)
+ new_project.project_feature.update!(builds_access_level: ProjectFeature::ENABLED)
end
end
@@ -855,7 +851,7 @@ RSpec.describe Projects::MergeRequestsController do
end
describe 'GET exposed_artifacts' do
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request,
:with_merge_request_pipeline,
target_project: project,
@@ -993,7 +989,7 @@ RSpec.describe Projects::MergeRequestsController do
end
describe 'GET coverage_reports' do
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request,
:with_merge_request_pipeline,
target_project: project,
@@ -1123,7 +1119,7 @@ RSpec.describe Projects::MergeRequestsController do
end
describe 'GET terraform_reports' do
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request,
:with_merge_request_pipeline,
target_project: project,
@@ -1271,7 +1267,7 @@ RSpec.describe Projects::MergeRequestsController do
end
describe 'GET test_reports' do
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request,
:with_diffs,
:with_merge_request_pipeline,
@@ -1382,7 +1378,7 @@ RSpec.describe Projects::MergeRequestsController do
end
describe 'GET accessibility_reports' do
- let(:merge_request) do
+ let_it_be(:merge_request) do
create(:merge_request,
:with_diffs,
:with_merge_request_pipeline,
@@ -1419,7 +1415,7 @@ RSpec.describe Projects::MergeRequestsController do
end
context 'permissions on a public project with private CI/CD' do
- let(:project) { create(:project, :repository, :public, :builds_private) }
+ let(:project) { project_public_with_private_builds }
let(:accessibility_comparison) { { status: :parsed, data: { summary: 1 } } }
context 'while signed out' do
@@ -1505,7 +1501,7 @@ RSpec.describe Projects::MergeRequestsController do
describe 'POST remove_wip' do
before do
merge_request.title = merge_request.wip_title
- merge_request.save
+ merge_request.save!
post :remove_wip,
params: {
@@ -1626,7 +1622,7 @@ RSpec.describe Projects::MergeRequestsController do
it 'links to the environment on that project', :sidekiq_might_not_need_inline do
get_ci_environments_status
- expect(json_response.first['url']).to match /#{forked.full_path}/
+ expect(json_response.first['url']).to match(/#{forked.full_path}/)
end
context "when environment_target is 'merge_commit'", :sidekiq_might_not_need_inline do
@@ -1653,7 +1649,7 @@ RSpec.describe Projects::MergeRequestsController do
get_ci_environments_status(environment_target: 'merge_commit')
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.first['url']).to match /#{project.full_path}/
+ expect(json_response.first['url']).to match(/#{project.full_path}/)
end
end
end
@@ -1773,7 +1769,7 @@ RSpec.describe Projects::MergeRequestsController do
context 'with project member visibility on a public project' do
let(:user) { create(:user) }
- let(:project) { create(:project, :repository, :public, :builds_private) }
+ let(:project) { project_public_with_private_builds }
it 'returns pipeline data to project members' do
project.add_developer(user)
@@ -1999,4 +1995,21 @@ RSpec.describe Projects::MergeRequestsController do
expect(assigns(:noteable)).not_to be_nil
end
end
+
+ describe 'POST export_csv' do
+ subject { post :export_csv, params: { namespace_id: project.namespace, project_id: project } }
+
+ it 'redirects to the merge request index' do
+ subject
+
+ expect(response).to redirect_to(project_merge_requests_path(project))
+ expect(response.flash[:notice]).to match(/\AYour CSV export has started/i)
+ end
+
+ it 'enqueues an IssuableExportCsvWorker worker' do
+ expect(IssuableExportCsvWorker).to receive(:perform_async).with(:merge_request, user.id, project.id, anything)
+
+ subject
+ end
+ end
end
diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb
index 8c59b2b378f..d76432f71b3 100644
--- a/spec/controllers/projects/notes_controller_spec.rb
+++ b/spec/controllers/projects/notes_controller_spec.rb
@@ -113,6 +113,8 @@ RSpec.describe Projects::NotesController do
end
it 'returns the first page of notes' do
+ expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
+
get :index, params: request_params
expect(json_response['notes'].count).to eq(page_1.count)
@@ -122,6 +124,8 @@ RSpec.describe Projects::NotesController do
end
it 'returns the second page of notes' do
+ expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
+
request.headers['X-Last-Fetched-At'] = page_1_boundary
get :index, params: request_params
@@ -133,6 +137,8 @@ RSpec.describe Projects::NotesController do
end
it 'returns the final page of notes' do
+ expect(Gitlab::EtagCaching::Middleware).to receive(:skip!)
+
request.headers['X-Last-Fetched-At'] = page_2_boundary
get :index, params: request_params
@@ -142,6 +148,19 @@ RSpec.describe Projects::NotesController do
expect(json_response['last_fetched_at']).to eq(microseconds(Time.zone.now))
expect(response.headers['Poll-Interval'].to_i).to be > 1
end
+
+ it 'returns an empty page of notes' do
+ expect(Gitlab::EtagCaching::Middleware).not_to receive(:skip!)
+
+ request.headers['X-Last-Fetched-At'] = microseconds(Time.zone.now)
+
+ get :index, params: request_params
+
+ expect(json_response['notes']).to be_empty
+ expect(json_response['more']).to be_falsy
+ expect(json_response['last_fetched_at']).to eq(microseconds(Time.zone.now))
+ expect(response.headers['Poll-Interval'].to_i).to be > 1
+ end
end
context 'feature flag disabled' do
diff --git a/spec/controllers/projects/raw_controller_spec.rb b/spec/controllers/projects/raw_controller_spec.rb
index b3921164c81..43cf1a16051 100644
--- a/spec/controllers/projects/raw_controller_spec.rb
+++ b/spec/controllers/projects/raw_controller_spec.rb
@@ -33,11 +33,6 @@ RSpec.describe Projects::RawController do
it_behaves_like 'project cache control headers'
it_behaves_like 'content disposition headers'
- it_behaves_like 'uncached response' do
- before do
- subject
- end
- end
end
context 'image header' do
@@ -225,6 +220,32 @@ RSpec.describe Projects::RawController do
end
end
end
+
+ describe 'caching' do
+ def request_file
+ get(:show, params: { namespace_id: project.namespace, project_id: project, id: 'master/README.md' })
+ end
+
+ it 'sets appropriate caching headers' do
+ sign_in create(:user)
+ request_file
+
+ expect(response.cache_control[:public]).to eq(true)
+ expect(response.cache_control[:max_age]).to eq(60)
+ expect(response.cache_control[:no_store]).to be_nil
+ end
+
+ context 'when If-None-Match header is set' do
+ it 'returns a 304 status' do
+ request_file
+
+ request.headers['If-None-Match'] = response.headers['ETag']
+ request_file
+
+ expect(response).to have_gitlab_http_status(:not_modified)
+ end
+ end
+ end
end
def execute_raw_requests(requests:, project:, file_path:, **params)
diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb
index 098fa9bac2c..9b803edd463 100644
--- a/spec/controllers/projects/registry/repositories_controller_spec.rb
+++ b/spec/controllers/projects/registry/repositories_controller_spec.rb
@@ -50,18 +50,17 @@ RSpec.describe Projects::Registry::RepositoriesController do
tags: %w[rc1 latest])
end
- it 'successfully renders container repositories' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
+ it 'successfully renders container repositories', :snowplow do
go_to_index
+ expect_no_snowplow_event
expect(response).to have_gitlab_http_status(:ok)
end
- it 'tracks the event' do
- expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_repositories', {})
-
+ it 'tracks the event', :snowplow do
go_to_index(format: :json)
+
+ expect_snowplow_event(category: anything, action: 'list_repositories')
end
it 'creates a root container repository' do
@@ -132,11 +131,12 @@ RSpec.describe Projects::Registry::RepositoriesController do
expect(response).to have_gitlab_http_status(:no_content)
end
- it 'tracks the event' do
- expect(Gitlab::Tracking).to receive(:event).with(anything, 'delete_repository', {})
+ it 'tracks the event', :snowplow do
allow(DeleteContainerRepositoryWorker).to receive(:perform_async).with(user.id, repository.id)
delete_repository(repository)
+
+ expect_snowplow_event(category: anything, action: 'delete_repository')
end
end
end
diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb
index 59df9e78a3c..5bff89b4308 100644
--- a/spec/controllers/projects/registry/tags_controller_spec.rb
+++ b/spec/controllers/projects/registry/tags_controller_spec.rb
@@ -39,10 +39,10 @@ RSpec.describe Projects::Registry::TagsController do
expect(response).to include_pagination_headers
end
- it 'tracks the event' do
- expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_tags', {})
-
+ it 'tracks the event', :snowplow do
get_tags
+
+ expect_snowplow_event(category: anything, action: 'list_tags')
end
end
@@ -109,7 +109,7 @@ RSpec.describe Projects::Registry::TagsController do
it 'tracks the event' do
expect_delete_tags(%w[test.])
- expect(controller).to receive(:track_event).with(:delete_tag, {})
+ expect(controller).to receive(:track_event).with(:delete_tag)
destroy_tag('test.')
end
@@ -148,11 +148,11 @@ RSpec.describe Projects::Registry::TagsController do
bulk_destroy_tags(tags)
end
- it 'tracks the event' do
+ it 'tracks the event', :snowplow do
expect_delete_tags(tags)
- expect(Gitlab::Tracking).to receive(:event).with(anything, 'delete_tag_bulk', {})
-
bulk_destroy_tags(tags)
+
+ expect_snowplow_event(category: anything, action: 'delete_tag_bulk')
end
end
end
diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb
index 420d818daeb..07fb03b39c6 100644
--- a/spec/controllers/projects/releases_controller_spec.rb
+++ b/spec/controllers/projects/releases_controller_spec.rb
@@ -201,102 +201,7 @@ RSpec.describe Projects::ReleasesController do
end
end
- context 'GET #downloads' do
- subject do
- get :downloads, params: { namespace_id: project.namespace, project_id: project, tag: tag, filepath: filepath }
- end
-
- before do
- sign_in(user)
- end
-
- let(:release) { create(:release, project: project, tag: tag ) }
- let!(:link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: 'https://downloads.example.com/bin/gitlab-linux-amd64') }
- let(:tag) { 'v11.9.0-rc2' }
-
- context 'valid filepath' do
- let(:filepath) { CGI.escape('/binaries/linux-amd64') }
-
- it 'redirects to the asset direct link' do
- subject
-
- expect(response).to redirect_to('https://downloads.example.com/bin/gitlab-linux-amd64')
- end
-
- it 'redirects with a status of 302' do
- subject
-
- expect(response).to have_gitlab_http_status(:redirect)
- end
- end
-
- context 'invalid filepath' do
- let(:filepath) { CGI.escape('/binaries/win32') }
-
- it 'is not found' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
- end
-
- context 'GET #downloads' do
- subject do
- get :downloads, params: {
- namespace_id: project.namespace,
- project_id: project,
- tag: tag,
- filepath: filepath
- }
- end
-
- before do
- sign_in(user)
- end
-
- let(:release) { create(:release, project: project, tag: tag ) }
- let(:tag) { 'v11.9.0-rc2' }
- let(:db_filepath) { '/binaries/linux-amd64' }
- let!(:link) do
- create :release_link,
- release: release,
- name: 'linux-amd64 binaries',
- filepath: db_filepath,
- url: 'https://downloads.example.com/bin/gitlab-linux-amd64'
- end
-
- context 'valid filepath' do
- let(:filepath) { CGI.escape('/binaries/linux-amd64') }
-
- it 'redirects to the asset direct link' do
- subject
-
- expect(response).to redirect_to(link.url)
- end
- end
-
- context 'invalid filepath' do
- let(:filepath) { CGI.escape('/binaries/win32') }
-
- it 'is not found' do
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'ignores filepath extension' do
- let(:db_filepath) { '/binaries/linux-amd64.json' }
- let(:filepath) { CGI.escape(db_filepath) }
-
- it 'redirects to the asset direct link' do
- subject
-
- expect(response).to redirect_to(link.url)
- end
- end
- end
+ # `GET #downloads` is addressed in spec/requests/projects/releases_controller_spec.rb
private
diff --git a/spec/controllers/projects/repositories_controller_spec.rb b/spec/controllers/projects/repositories_controller_spec.rb
index 97eea7c7e9d..e7f4a8a1422 100644
--- a/spec/controllers/projects/repositories_controller_spec.rb
+++ b/spec/controllers/projects/repositories_controller_spec.rb
@@ -122,7 +122,9 @@ RSpec.describe Projects::RepositoriesController do
expect(response).to have_gitlab_http_status(:ok)
expect(response.header['ETag']).to be_present
- expect(response.header['Cache-Control']).to include('max-age=60, private')
+ expect(response.cache_control[:public]).to eq(false)
+ expect(response.cache_control[:max_age]).to eq(60)
+ expect(response.cache_control[:no_store]).to be_nil
end
context 'when project is public' do
diff --git a/spec/controllers/projects/settings/operations_controller_spec.rb b/spec/controllers/projects/settings/operations_controller_spec.rb
index 9fc9da1265e..46f69eaf96a 100644
--- a/spec/controllers/projects/settings/operations_controller_spec.rb
+++ b/spec/controllers/projects/settings/operations_controller_spec.rb
@@ -166,23 +166,22 @@ RSpec.describe Projects::Settings::OperationsController do
context 'updating each incident management setting' do
let(:new_incident_management_settings) { {} }
- shared_examples 'a gitlab tracking event' do |params, event_key|
- it "creates a gitlab tracking event #{event_key}" do
+ shared_examples 'a gitlab tracking event' do |params, event_key, **args|
+ it "creates a gitlab tracking event #{event_key}", :snowplow do
new_incident_management_settings = params
- expect(Gitlab::Tracking).to receive(:event)
- .with('IncidentManagement::Settings', event_key, any_args)
-
patch :update, params: project_params(project, incident_management_setting_attributes: new_incident_management_settings)
project.reload
+
+ expect_snowplow_event(category: 'IncidentManagement::Settings', action: event_key, **args)
end
end
it_behaves_like 'a gitlab tracking event', { create_issue: '1' }, 'enabled_issue_auto_creation_on_alerts'
it_behaves_like 'a gitlab tracking event', { create_issue: '0' }, 'disabled_issue_auto_creation_on_alerts'
- it_behaves_like 'a gitlab tracking event', { issue_template_key: 'template' }, 'enabled_issue_template_on_alerts'
- it_behaves_like 'a gitlab tracking event', { issue_template_key: nil }, 'disabled_issue_template_on_alerts'
+ it_behaves_like 'a gitlab tracking event', { issue_template_key: 'template' }, 'enabled_issue_template_on_alerts', label: "Template name", property: "template"
+ it_behaves_like 'a gitlab tracking event', { issue_template_key: nil }, 'disabled_issue_template_on_alerts', label: "Template name", property: ""
it_behaves_like 'a gitlab tracking event', { send_email: '1' }, 'enabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { send_email: '0' }, 'disabled_sending_emails'
it_behaves_like 'a gitlab tracking event', { pagerduty_active: '1' }, 'enabled_pagerduty_webhook'
diff --git a/spec/controllers/projects/settings/repository_controller_spec.rb b/spec/controllers/projects/settings/repository_controller_spec.rb
index d93f23ae142..394f1ff28f2 100644
--- a/spec/controllers/projects/settings/repository_controller_spec.rb
+++ b/spec/controllers/projects/settings/repository_controller_spec.rb
@@ -23,13 +23,15 @@ RSpec.describe Projects::Settings::RepositoryController do
describe 'PUT cleanup' do
let(:object_map) { fixture_file_upload('spec/fixtures/bfg_object_map.txt') }
- it 'enqueues a RepositoryCleanupWorker' do
- allow(RepositoryCleanupWorker).to receive(:perform_async)
+ it 'enqueues a project cleanup' do
+ expect(Projects::CleanupService)
+ .to receive(:enqueue)
+ .with(project, user, anything)
+ .and_return(status: :success)
- put :cleanup, params: { namespace_id: project.namespace, project_id: project, project: { object_map: object_map } }
+ put :cleanup, params: { namespace_id: project.namespace, project_id: project, project: { bfg_object_map: object_map } }
expect(response).to redirect_to project_settings_repository_path(project)
- expect(RepositoryCleanupWorker).to have_received(:perform_async).once
end
end
diff --git a/spec/controllers/projects/snippets_controller_spec.rb b/spec/controllers/projects/snippets_controller_spec.rb
index 6b394fab14c..f9221c5a4ef 100644
--- a/spec/controllers/projects/snippets_controller_spec.rb
+++ b/spec/controllers/projects/snippets_controller_spec.rb
@@ -180,16 +180,6 @@ RSpec.describe Projects::SnippetsController do
end
end
- describe 'GET #show as JSON' do
- it 'renders the blob from the repository' do
- project_snippet = create(:project_snippet, :public, :repository, project: project, author: user)
-
- get :show, params: { namespace_id: project.namespace, project_id: project, id: project_snippet.to_param }, format: :json
-
- expect(assigns(:blob)).to eq(project_snippet.blobs.first)
- end
- end
-
describe "GET #show for embeddable content" do
let(:project_snippet) { create(:project_snippet, :repository, snippet_permission, project: project, author: user) }
let(:extra_params) { {} }
diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb
index 6ea730cbf27..867b2b51039 100644
--- a/spec/controllers/projects/static_site_editor_controller_spec.rb
+++ b/spec/controllers/projects/static_site_editor_controller_spec.rb
@@ -105,7 +105,8 @@ RSpec.describe Projects::StaticSiteEditorController do
foo: 'bar'
}
},
- a_boolean: true
+ a_boolean: true,
+ a_nil: nil
}
end
@@ -130,6 +131,10 @@ RSpec.describe Projects::StaticSiteEditorController do
it 'serializes data values which are hashes to JSON' do
expect(assigns_data[:a_hash]).to eq('{"a_deeper_hash":{"foo":"bar"}}')
end
+
+ it 'serializes data values which are nil to an empty string' do
+ expect(assigns_data[:a_nil]).to eq('')
+ end
end
end
end
diff --git a/spec/controllers/projects/tags_controller_spec.rb b/spec/controllers/projects/tags_controller_spec.rb
index 57760088183..efb57494f82 100644
--- a/spec/controllers/projects/tags_controller_spec.rb
+++ b/spec/controllers/projects/tags_controller_spec.rb
@@ -9,18 +9,75 @@ RSpec.describe Projects::TagsController do
let(:user) { create(:user) }
describe 'GET index' do
- before do
- get :index, params: { namespace_id: project.namespace.to_param, project_id: project }
- end
+ subject { get :index, params: { namespace_id: project.namespace.to_param, project_id: project } }
it 'returns the tags for the page' do
+ subject
+
expect(assigns(:tags).map(&:name)).to include('v1.1.0', 'v1.0.0')
end
it 'returns releases matching those tags' do
+ subject
+
expect(assigns(:releases)).to include(release)
expect(assigns(:releases)).not_to include(invalid_release)
end
+
+ context '@tag_pipeline_status' do
+ context 'when no pipelines exist' do
+ it 'is empty' do
+ subject
+
+ expect(assigns(:tag_pipeline_statuses)).to be_empty
+ end
+ end
+
+ context 'when multiple tags exist' do
+ before do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.1.0',
+ sha: project.commit('v1.1.0').sha,
+ status: :running)
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :success)
+ end
+
+ it 'all relevant commit statuses are received' do
+ subject
+
+ expect(assigns(:tag_pipeline_statuses)['v1.1.0'].group).to eq("running")
+ expect(assigns(:tag_pipeline_statuses)['v1.0.0'].group).to eq("success")
+ end
+ end
+
+ context 'when a tag has multiple pipelines' do
+ before do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :running,
+ created_at: 6.months.ago)
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :success,
+ created_at: 2.months.ago)
+ end
+
+ it 'chooses the latest to determine status' do
+ subject
+
+ expect(assigns(:tag_pipeline_statuses)['v1.0.0'].group).to eq("success")
+ end
+ end
+ end
end
describe 'GET show' do
@@ -70,7 +127,8 @@ RSpec.describe Projects::TagsController do
end
let(:release_description) { nil }
- let(:request) do
+
+ subject(:request) do
post(:create, params: {
namespace_id: project.namespace.to_param,
project_id: project,
@@ -81,7 +139,7 @@ RSpec.describe Projects::TagsController do
end
it 'creates tag' do
- request
+ subject
expect(response).to have_gitlab_http_status(:found)
expect(project.repository.find_tag('1.0')).to be_present
@@ -92,7 +150,7 @@ RSpec.describe Projects::TagsController do
let(:release_description) { 'some release description' }
it 'creates tag and release' do
- request
+ subject
expect(response).to have_gitlab_http_status(:found)
expect(project.repository.find_tag('1.0')).to be_present
@@ -118,7 +176,7 @@ RSpec.describe Projects::TagsController do
expect(service).to receive(:execute).and_call_original
end
- request
+ subject
aggregate_failures do
expect(response).to have_gitlab_http_status(:found)
diff --git a/spec/controllers/projects/templates_controller_spec.rb b/spec/controllers/projects/templates_controller_spec.rb
index 40632e0dea7..01593f4133c 100644
--- a/spec/controllers/projects/templates_controller_spec.rb
+++ b/spec/controllers/projects/templates_controller_spec.rb
@@ -5,35 +5,84 @@ require 'spec_helper'
RSpec.describe Projects::TemplatesController do
let(:project) { create(:project, :repository, :private) }
let(:user) { create(:user) }
- let(:file_path_1) { '.gitlab/issue_templates/issue_template.md' }
- let(:file_path_2) { '.gitlab/merge_request_templates/merge_request_template.md' }
- let!(:file_1) { project.repository.create_file(user, file_path_1, 'issue content', message: 'message', branch_name: 'master') }
- let!(:file_2) { project.repository.create_file(user, file_path_2, 'merge request content', message: 'message', branch_name: 'master') }
+ let(:issue_template_path_1) { '.gitlab/issue_templates/issue_template_1.md' }
+ let(:issue_template_path_2) { '.gitlab/issue_templates/issue_template_2.md' }
+ let(:merge_request_template_path_1) { '.gitlab/merge_request_templates/merge_request_template_1.md' }
+ let(:merge_request_template_path_2) { '.gitlab/merge_request_templates/merge_request_template_2.md' }
+ let!(:issue_template_file_1) { project.repository.create_file(user, issue_template_path_1, 'issue content 1', message: 'message 1', branch_name: 'master') }
+ let!(:issue_template_file_2) { project.repository.create_file(user, issue_template_path_2, 'issue content 2', message: 'message 2', branch_name: 'master') }
+ let!(:merge_request_template_file_1) { project.repository.create_file(user, merge_request_template_path_1, 'merge request content 1', message: 'message 1', branch_name: 'master') }
+ let!(:merge_request_template_file_2) { project.repository.create_file(user, merge_request_template_path_2, 'merge request content 2', message: 'message 2', branch_name: 'master') }
+ let(:expected_issue_template_1) { { 'key' => 'issue_template_1', 'name' => 'issue_template_1', 'content' => 'issue content 1' } }
+ let(:expected_issue_template_2) { { 'key' => 'issue_template_2', 'name' => 'issue_template_2', 'content' => 'issue content 2' } }
+ let(:expected_merge_request_template_1) { { 'key' => 'merge_request_template_1', 'name' => 'merge_request_template_1', 'content' => 'merge request content 1' } }
+ let(:expected_merge_request_template_2) { { 'key' => 'merge_request_template_2', 'name' => 'merge_request_template_2', 'content' => 'merge request content 2' } }
+
+ describe '#index' do
+ before do
+ project.add_developer(user)
+ sign_in(user)
+ end
+
+ shared_examples 'templates request' do
+ it 'returns the templates' do
+ get(:index, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to match(expected_templates)
+ end
+
+ it 'fails for user with no access' do
+ other_user = create(:user)
+ sign_in(other_user)
+
+ get(:index, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when querying for issue templates' do
+ it_behaves_like 'templates request' do
+ let(:template_type) { 'issue' }
+ let(:expected_templates) { [expected_issue_template_1, expected_issue_template_2] }
+ end
+ end
+
+ context 'when querying for merge_request templates' do
+ it_behaves_like 'templates request' do
+ let(:template_type) { 'merge_request' }
+ let(:expected_templates) { [expected_merge_request_template_1, expected_merge_request_template_2] }
+ end
+ end
+ end
describe '#show' do
shared_examples 'renders issue templates as json' do
+ let(:expected_issue_template) { expected_issue_template_2 }
+
it do
- get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template', project_id: project }, format: :json)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template_2', project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq('issue_template')
- expect(json_response['content']).to eq('issue content')
+ expect(json_response).to match(expected_issue_template)
end
end
shared_examples 'renders merge request templates as json' do
+ let(:expected_merge_request_template) { expected_merge_request_template_2 }
+
it do
- get(:show, params: { namespace_id: project.namespace, template_type: 'merge_request', key: 'merge_request_template', project_id: project }, format: :json)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'merge_request', key: 'merge_request_template_2', project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq('merge_request_template')
- expect(json_response['content']).to eq('merge request content')
+ expect(json_response).to match(expected_merge_request_template)
end
end
shared_examples 'renders 404 when requesting an issue template' do
it do
- get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template', project_id: project }, format: :json)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template_1', project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -41,21 +90,23 @@ RSpec.describe Projects::TemplatesController do
shared_examples 'renders 404 when requesting a merge request template' do
it do
- get(:show, params: { namespace_id: project.namespace, template_type: 'merge_request', key: 'merge_request_template', project_id: project }, format: :json)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'merge_request', key: 'merge_request_template_1', project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:not_found)
end
end
- shared_examples 'renders 404 when params are invalid' do
+ shared_examples 'raises error when template type is invalid' do
it 'does not route when the template type is invalid' do
expect do
- get(:show, params: { namespace_id: project.namespace, template_type: 'invalid_type', key: 'issue_template', project_id: project }, format: :json)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'invalid_type', key: 'issue_template_1', project_id: project }, format: :json)
end.to raise_error(ActionController::UrlGenerationError)
end
+ end
+ shared_examples 'renders 404 when params are invalid' do
it 'renders 404 when the format type is invalid' do
- get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template', project_id: project }, format: :html)
+ get(:show, params: { namespace_id: project.namespace, template_type: 'issue', key: 'issue_template_1', project_id: project }, format: :html)
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -74,7 +125,6 @@ RSpec.describe Projects::TemplatesController do
include_examples 'renders 404 when requesting an issue template'
include_examples 'renders 404 when requesting a merge request template'
- include_examples 'renders 404 when params are invalid'
end
context 'when user is a member of the project' do
@@ -85,7 +135,11 @@ RSpec.describe Projects::TemplatesController do
include_examples 'renders issue templates as json'
include_examples 'renders merge request templates as json'
- include_examples 'renders 404 when params are invalid'
+
+ context 'when params are invalid' do
+ include_examples 'raises error when template type is invalid'
+ include_examples 'renders 404 when params are invalid'
+ end
end
context 'when user is a guest of the project' do
@@ -96,7 +150,6 @@ RSpec.describe Projects::TemplatesController do
include_examples 'renders issue templates as json'
include_examples 'renders 404 when requesting a merge request template'
- include_examples 'renders 404 when params are invalid'
end
end
@@ -111,8 +164,8 @@ RSpec.describe Projects::TemplatesController do
get(:names, params: { namespace_id: project.namespace, template_type: template_type, project_id: project }, format: :json)
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.size).to eq(1)
- expect(json_response[0]['name']).to eq(expected_template_name)
+ expect(json_response.size).to eq(2)
+ expect(json_response).to match(expected_template_names)
end
it 'fails for user with no access' do
@@ -128,14 +181,14 @@ RSpec.describe Projects::TemplatesController do
context 'when querying for issue templates' do
it_behaves_like 'template names request' do
let(:template_type) { 'issue' }
- let(:expected_template_name) { 'issue_template' }
+ let(:expected_template_names) { [{ 'name' => 'issue_template_1' }, { 'name' => 'issue_template_2' }] }
end
end
context 'when querying for merge_request templates' do
it_behaves_like 'template names request' do
let(:template_type) { 'merge_request' }
- let(:expected_template_name) { 'merge_request_template' }
+ let(:expected_template_names) { [{ 'name' => 'merge_request_template_1' }, { 'name' => 'merge_request_template_2' }] }
end
end
end
diff --git a/spec/controllers/projects/terraform_controller_spec.rb b/spec/controllers/projects/terraform_controller_spec.rb
new file mode 100644
index 00000000000..1978b9494fa
--- /dev/null
+++ b/spec/controllers/projects/terraform_controller_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::TerraformController do
+ let_it_be(:project) { create(:project) }
+
+ describe 'GET index' do
+ subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
+
+ context 'when user is authorized' do
+ let(:user) { project.creator }
+
+ before do
+ sign_in(user)
+ subject
+ end
+
+ it 'renders content' do
+ expect(response).to be_successful
+ end
+ end
+
+ context 'when user is unauthorized' do
+ let(:user) { create(:user) }
+
+ before do
+ project.add_guest(user)
+ sign_in(user)
+ subject
+ end
+
+ it 'shows 404' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb
index 0640f9e5724..012a98f433e 100644
--- a/spec/controllers/projects_controller_spec.rb
+++ b/spec/controllers/projects_controller_spec.rb
@@ -1437,4 +1437,55 @@ RSpec.describe ProjectsController do
def project_moved_message(redirect_route, project)
"Project '#{redirect_route.path}' was moved to '#{project.full_path}'. Please update any links and bookmarks that may still have the old path."
end
+
+ describe 'GET #unfoldered_environment_names' do
+ it 'shows the environment names of a public project to an anonymous user' do
+ create(:environment, project: public_project, name: 'foo')
+
+ get(
+ :unfoldered_environment_names,
+ params: { namespace_id: public_project.namespace, id: public_project, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(%w[foo])
+ end
+
+ it 'does not show environment names of a private project to anonymous users' do
+ create(:environment, project: project, name: 'foo')
+
+ get(
+ :unfoldered_environment_names,
+ params: { namespace_id: project.namespace, id: project, format: :json }
+ )
+
+ expect(response).to redirect_to(new_user_session_path)
+ end
+
+ it 'shows environment names of a private project to a project member' do
+ create(:environment, project: project, name: 'foo')
+ project.add_developer(user)
+ sign_in(user)
+
+ get(
+ :unfoldered_environment_names,
+ params: { namespace_id: project.namespace, id: project, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to eq(%w[foo])
+ end
+
+ it 'does not show environment names of a private project to a logged-in non-member' do
+ create(:environment, project: project, name: 'foo')
+ sign_in(user)
+
+ get(
+ :unfoldered_environment_names,
+ params: { namespace_id: project.namespace, id: project, format: :json }
+ )
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
diff --git a/spec/controllers/registrations/welcome_controller_spec.rb b/spec/controllers/registrations/welcome_controller_spec.rb
new file mode 100644
index 00000000000..d32c936b8c9
--- /dev/null
+++ b/spec/controllers/registrations/welcome_controller_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Registrations::WelcomeController do
+ let(:user) { create(:user) }
+
+ describe '#welcome' do
+ subject(:show) { get :show }
+
+ context 'without a signed in user' do
+ it { is_expected.to redirect_to new_user_registration_path }
+ end
+
+ context 'when role or setup_for_company is not set' do
+ before do
+ sign_in(user)
+ end
+
+ it { is_expected.to render_template(:show) }
+ end
+
+ context 'when role is required and setup_for_company is not set' do
+ before do
+ user.set_role_required!
+ sign_in(user)
+ end
+
+ it { is_expected.to render_template(:show) }
+ end
+
+ context 'when role and setup_for_company is set' do
+ before do
+ user.update!(setup_for_company: false)
+ sign_in(user)
+ end
+
+ it { is_expected.to redirect_to(dashboard_projects_path)}
+ end
+
+ context 'when role is set and setup_for_company is not set' do
+ before do
+ user.update!(role: :software_developer)
+ sign_in(user)
+ end
+
+ it { is_expected.to render_template(:show) }
+ 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
+
+ describe '#update' do
+ subject(:update) do
+ patch :update, params: { user: { role: 'software_developer', setup_for_company: 'false' } }
+ end
+
+ context 'without a signed in user' do
+ it { is_expected.to redirect_to new_user_registration_path }
+ end
+
+ context 'with a signed in user' do
+ before do
+ sign_in(user)
+ end
+
+ it { is_expected.to redirect_to(dashboard_projects_path)}
+ end
+ end
+end
diff --git a/spec/controllers/registrations_controller_spec.rb b/spec/controllers/registrations_controller_spec.rb
index 501d8d4a78d..2fb17e56f37 100644
--- a/spec/controllers/registrations_controller_spec.rb
+++ b/spec/controllers/registrations_controller_spec.rb
@@ -7,35 +7,16 @@ RSpec.describe RegistrationsController do
before do
stub_feature_flags(invisible_captcha: false)
+ stub_application_setting(require_admin_approval_after_user_signup: false)
end
describe '#new' do
subject { get :new }
- context 'with the experimental signup flow enabled and the user is part of the experimental group' do
- before do
- stub_experiment(signup_flow: true)
- stub_experiment_for_user(signup_flow: true)
- end
-
- it 'renders new template and sets the resource variable' do
- expect(subject).to render_template(:new)
- expect(response).to have_gitlab_http_status(:ok)
- expect(assigns(:resource)).to be_a(User)
- end
- end
-
- context 'with the experimental signup flow enabled and the user is part of the control group' do
- before do
- stub_experiment(signup_flow: true)
- stub_experiment_for_user(signup_flow: false)
- end
-
- it 'renders new template and sets the resource variable' do
- subject
- expect(response).to have_gitlab_http_status(:found)
- expect(response).to redirect_to(new_user_session_path(anchor: 'register-pane'))
- end
+ it 'renders new template and sets the resource variable' do
+ expect(subject).to render_template(:new)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(assigns(:resource)).to be_a(User)
end
end
@@ -46,102 +27,86 @@ RSpec.describe RegistrationsController do
subject { post(:create, params: user_params) }
context '`blocked_pending_approval` state' do
- context 'when the feature is enabled' do
+ context 'when the `require_admin_approval_after_user_signup` setting is turned on' do
before do
- stub_feature_flags(admin_approval_for_new_user_signups: true)
+ stub_application_setting(require_admin_approval_after_user_signup: true)
end
- context 'when the `require_admin_approval_after_user_signup` setting is turned on' do
- before do
- stub_application_setting(require_admin_approval_after_user_signup: true)
- end
+ it 'signs up the user in `blocked_pending_approval` state' do
+ subject
+ created_user = User.find_by(email: 'new@user.com')
- it 'signs up the user in `blocked_pending_approval` state' do
- subject
- created_user = User.find_by(email: 'new@user.com')
+ expect(created_user).to be_present
+ expect(created_user.blocked_pending_approval?).to eq(true)
+ end
- expect(created_user).to be_present
- expect(created_user.blocked_pending_approval?).to eq(true)
- end
+ it 'does not log in the user after sign up' do
+ subject
- it 'does not log in the user after sign up' do
- subject
+ expect(controller.current_user).to be_nil
+ end
- expect(controller.current_user).to be_nil
- end
+ it 'shows flash message after signing up' do
+ subject
- it 'shows flash message after signing up' do
- subject
+ expect(response).to redirect_to(new_user_session_path(anchor: 'login-pane'))
+ expect(flash[:notice])
+ .to eq('You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your GitLab administrator.')
+ end
- expect(response).to redirect_to(new_user_session_path(anchor: 'login-pane'))
- expect(flash[:notice])
- .to eq('You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your GitLab administrator.')
+ it 'emails the access request to approvers' do
+ expect_next_instance_of(NotificationService) do |notification|
+ allow(notification).to receive(:new_instance_access_request).with(User.find_by(email: 'new@user.com'))
end
- context 'email confirmation' do
- context 'when `send_user_confirmation_email` is true' do
- before do
- stub_application_setting(send_user_confirmation_email: true)
- end
-
- it 'does not send a confirmation email' do
- expect { subject }
- .not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- end
- end
- end
+ subject
end
- context 'when the `require_admin_approval_after_user_signup` setting is turned off' do
- before do
- stub_application_setting(require_admin_approval_after_user_signup: false)
- end
-
- it 'signs up the user in `active` state' do
- subject
- created_user = User.find_by(email: 'new@user.com')
+ context 'email confirmation' do
+ context 'when `send_user_confirmation_email` is true' do
+ before do
+ stub_application_setting(send_user_confirmation_email: true)
+ end
- expect(created_user).to be_present
- expect(created_user.active?).to eq(true)
+ it 'does not send a confirmation email' do
+ expect { subject }
+ .not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ end
end
+ end
+ end
- it 'does not show any flash message after signing up' do
- subject
+ context 'when the `require_admin_approval_after_user_signup` setting is turned off' do
+ it 'signs up the user in `active` state' do
+ subject
+ created_user = User.find_by(email: 'new@user.com')
- expect(flash[:notice]).to be_nil
- end
+ expect(created_user).to be_present
+ expect(created_user.active?).to eq(true)
+ end
- context 'email confirmation' do
- context 'when `send_user_confirmation_email` is true' do
- before do
- stub_application_setting(send_user_confirmation_email: true)
- end
+ it 'does not show any flash message after signing up' do
+ subject
- it 'sends a confirmation email' do
- expect { subject }
- .to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
- end
- end
- end
+ expect(flash[:notice]).to be_nil
end
- end
- context 'when the feature is disabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: false)
- end
+ it 'does not email the approvers' do
+ expect(NotificationService).not_to receive(:new)
- context 'when the `require_admin_approval_after_user_signup` setting is turned on' do
- before do
- stub_application_setting(require_admin_approval_after_user_signup: true)
- end
+ subject
+ end
- it 'signs up the user in `active` state' do
- subject
+ context 'email confirmation' do
+ context 'when `send_user_confirmation_email` is true' do
+ before do
+ stub_application_setting(send_user_confirmation_email: true)
+ end
- created_user = User.find_by(email: 'new@user.com')
- expect(created_user).to be_present
- expect(created_user.active?).to eq(true)
+ it 'sends a confirmation email' do
+ expect { subject }
+ .to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ end
end
end
end
@@ -448,45 +413,4 @@ RSpec.describe RegistrationsController do
end
end
end
-
- describe '#welcome' do
- subject { get :welcome }
-
- 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
-
- describe '#update_registration' do
- subject(:update_registration) do
- patch :update_registration, params: { user: { role: 'software_developer', setup_for_company: 'false' } }
- end
-
- context 'without a signed in user' do
- it { is_expected.to redirect_to new_user_registration_path }
- end
-
- context 'with a signed in user' do
- before do
- sign_in(create(:user))
- end
-
- it { is_expected.to redirect_to(dashboard_projects_path)}
- end
- end
end
diff --git a/spec/controllers/repositories/lfs_storage_controller_spec.rb b/spec/controllers/repositories/lfs_storage_controller_spec.rb
index 0201e73728f..4f9d049cf87 100644
--- a/spec/controllers/repositories/lfs_storage_controller_spec.rb
+++ b/spec/controllers/repositories/lfs_storage_controller_spec.rb
@@ -127,6 +127,41 @@ RSpec.describe Repositories::LfsStorageController do
end
end
+ context 'when existing file has been deleted' do
+ let(:lfs_object) { create(:lfs_object, :with_file) }
+
+ before do
+ FileUtils.rm(lfs_object.file.path)
+ params[:oid] = lfs_object.oid
+ params[:size] = lfs_object.size
+ end
+
+ it 'replaces the file' do
+ expect(Gitlab::AppJsonLogger).to receive(:info).with(message: "LFS file replaced because it did not exist", oid: lfs_object.oid, size: lfs_object.size)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(lfs_object.reload.file).to exist
+ end
+
+ context 'with invalid file' do
+ before do
+ allow_next_instance_of(ActionController::Parameters) do |params|
+ allow(params).to receive(:[]).and_call_original
+ allow(params).to receive(:[]).with(:file).and_return({})
+ end
+ end
+
+ it 'renders LFS forbidden' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(lfs_object.reload.file).not_to exist
+ end
+ end
+ end
+
context 'when file is not stored' do
it 'renders unprocessable entity' do
expect(controller).to receive(:store_file!).and_return(nil)
diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb
index a0cb696828d..afebc6982c1 100644
--- a/spec/controllers/search_controller_spec.rb
+++ b/spec/controllers/search_controller_spec.rb
@@ -268,12 +268,15 @@ RSpec.describe SearchController do
last_payload = payload
end
- get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456' }
+ get :show, params: { scope: 'issues', search: 'hello world', group_id: '123', project_id: '456', confidential: true, state: true, force_search_results: true }
expect(last_payload[:metadata]['meta.search.group_id']).to eq('123')
expect(last_payload[:metadata]['meta.search.project_id']).to eq('456')
expect(last_payload[:metadata]['meta.search.search']).to eq('hello world')
expect(last_payload[:metadata]['meta.search.scope']).to eq('issues')
+ expect(last_payload[:metadata]['meta.search.force_search_results']).to eq('true')
+ expect(last_payload[:metadata]['meta.search.filters.confidential']).to eq('true')
+ expect(last_payload[:metadata]['meta.search.filters.state']).to eq('true')
end
end
end
diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb
index 75bcc32e6f3..c31ba6fe156 100644
--- a/spec/controllers/sessions_controller_spec.rb
+++ b/spec/controllers/sessions_controller_spec.rb
@@ -86,7 +86,7 @@ RSpec.describe SessionsController do
post(:create, params: { user: { login: 'invalid', password: 'invalid' } })
expect(response)
- .to set_flash.now[:alert].to /Invalid Login or password/
+ .to set_flash.now[:alert].to(/Invalid Login or password/)
end
end
@@ -299,7 +299,7 @@ RSpec.describe SessionsController do
context 'when using two-factor authentication via OTP' do
let(:user) { create(:user, :two_factor) }
- def authenticate_2fa(user_params, otp_user_id: user.id)
+ def authenticate_2fa(otp_user_id: user.id, **user_params)
post(:create, params: { user: user_params }, session: { otp_user_id: otp_user_id })
end
@@ -343,11 +343,12 @@ RSpec.describe SessionsController do
it 'favors login over otp_user_id when password is present and does not authenticate the user' do
authenticate_2fa(
- { login: 'random_username', password: user.password },
+ login: 'random_username',
+ password: user.password,
otp_user_id: user.id
)
- expect(response).to set_flash.now[:alert].to /Invalid Login or password/
+ expect(response).to set_flash.now[:alert].to(/Invalid Login or password/)
end
end
@@ -396,7 +397,7 @@ RSpec.describe SessionsController do
it 'warns about invalid OTP code' do
expect(response).to set_flash.now[:alert]
- .to /Invalid two-factor code/
+ .to(/Invalid two-factor code/)
end
end
end
diff --git a/spec/controllers/snippets_controller_spec.rb b/spec/controllers/snippets_controller_spec.rb
index 1ccba7f9114..993ab5d1c72 100644
--- a/spec/controllers/snippets_controller_spec.rb
+++ b/spec/controllers/snippets_controller_spec.rb
@@ -205,14 +205,6 @@ RSpec.describe SnippetsController do
end
end
end
-
- context 'when requesting JSON' do
- it 'renders the blob from the repository' do
- get :show, params: { id: public_snippet.to_param }, format: :json
-
- expect(assigns(:blob)).to eq(public_snippet.blobs.first)
- end
- end
end
describe 'POST #mark_as_spam' do
diff --git a/spec/crystalball_env.rb b/spec/crystalball_env.rb
new file mode 100644
index 00000000000..a7748cd6627
--- /dev/null
+++ b/spec/crystalball_env.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module CrystalballEnv
+ EXCLUDED_PREFIXES = %w[vendor/ruby].freeze
+
+ extend self
+
+ def start!
+ return unless ENV['CRYSTALBALL'] && ENV['CI_PIPELINE_SOURCE'] == 'schedule' && ENV['FREQUENCY'] == '2-hourly'
+
+ require 'crystalball'
+ require_relative '../tooling/lib/tooling/crystalball/coverage_lines_execution_detector'
+ require_relative '../tooling/lib/tooling/crystalball/coverage_lines_strategy'
+
+ map_storage_path_base = ENV['CI_JOB_NAME'] || 'crystalball_data'
+ map_storage_path = "crystalball/#{map_storage_path_base.gsub(%r{[/ ]}, '_')}.yml"
+
+ execution_detector = Tooling::Crystalball::CoverageLinesExecutionDetector.new(exclude_prefixes: EXCLUDED_PREFIXES)
+
+ Crystalball::MapGenerator.start! do |config|
+ config.map_storage_path = map_storage_path
+ config.register Tooling::Crystalball::CoverageLinesStrategy.new(execution_detector)
+ end
+ end
+end
diff --git a/spec/db/production/settings_spec.rb b/spec/db/production/settings_spec.rb
index 84f7ae12728..f17720466c0 100644
--- a/spec/db/production/settings_spec.rb
+++ b/spec/db/production/settings_spec.rb
@@ -62,4 +62,11 @@ RSpec.describe 'seed production settings' do
end
end
end
+
+ context 'CI JWT signing key' do
+ it 'writes valid RSA key to the database' do
+ expect { load(settings_file) }.to change { settings.reload.ci_jwt_signing_key }.from(nil)
+ expect { OpenSSL::PKey::RSA.new(settings.ci_jwt_signing_key) }.not_to raise_error
+ end
+ end
end
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index 06fafbddced..c35f3831a58 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -241,25 +241,9 @@ RSpec.describe 'Database schema' do
context 'primary keys' do
let(:exceptions) do
%i(
- analytics_language_trend_repository_languages
- approval_project_rules_protected_branches
- ci_build_trace_sections
- deployment_merge_requests
elasticsearch_indexed_namespaces
elasticsearch_indexed_projects
- issue_assignees
- issues_prometheus_alert_events
- issues_self_managed_prometheus_alert_events
merge_request_context_commit_diff_files
- merge_request_diff_commits
- merge_request_diff_files
- milestone_releases
- project_authorizations
- project_pages_metadata
- push_event_payloads
- repository_languages
- user_interacted_projects
- users_security_dashboard_projects
)
end
diff --git a/spec/factories/alert_management/http_integrations.rb b/spec/factories/alert_management/http_integrations.rb
index 9311cb3e114..2b5864c8587 100644
--- a/spec/factories/alert_management/http_integrations.rb
+++ b/spec/factories/alert_management/http_integrations.rb
@@ -10,5 +10,11 @@ FactoryBot.define do
trait :inactive do
active { false }
end
+
+ trait :legacy do
+ endpoint_identifier { 'legacy' }
+ end
+
+ initialize_with { new(**attributes) }
end
end
diff --git a/spec/factories/alerts_service_data.rb b/spec/factories/alerts_service_data.rb
new file mode 100644
index 00000000000..2dd1d0a714e
--- /dev/null
+++ b/spec/factories/alerts_service_data.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :alerts_service_data do
+ service { association(:alerts_service) }
+ token { SecureRandom.hex }
+ end
+end
diff --git a/spec/factories/analytics/devops_adoption/segment_selections.rb b/spec/factories/analytics/devops_adoption/segment_selections.rb
new file mode 100644
index 00000000000..8f10369ba35
--- /dev/null
+++ b/spec/factories/analytics/devops_adoption/segment_selections.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :devops_adoption_segment_selection, class: 'Analytics::DevopsAdoption::SegmentSelection' do
+ association :segment, factory: :devops_adoption_segment
+ project
+
+ trait :project do
+ group { nil }
+ project
+ end
+
+ trait :group do
+ project { nil }
+ group
+ end
+ end
+end
diff --git a/spec/factories/analytics/devops_adoption/segments.rb b/spec/factories/analytics/devops_adoption/segments.rb
new file mode 100644
index 00000000000..367ee01fa18
--- /dev/null
+++ b/spec/factories/analytics/devops_adoption/segments.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :devops_adoption_segment, class: 'Analytics::DevopsAdoption::Segment' do
+ sequence(:name) { |n| "Segment #{n}" }
+ end
+end
diff --git a/spec/factories/instance_statistics/measurement.rb b/spec/factories/analytics/instance_statistics/measurement.rb
index f9398cd3061..f9398cd3061 100644
--- a/spec/factories/instance_statistics/measurement.rb
+++ b/spec/factories/analytics/instance_statistics/measurement.rb
diff --git a/spec/factories/audit_events.rb b/spec/factories/audit_events.rb
index 5497648273c..4e72976a9e5 100644
--- a/spec/factories/audit_events.rb
+++ b/spec/factories/audit_events.rb
@@ -4,7 +4,7 @@ FactoryBot.define do
factory :audit_event, class: 'AuditEvent', aliases: [:user_audit_event] do
user
- transient { target_user { create(:user) } }
+ transient { target_user { association(:user) } }
entity_type { 'User' }
entity_id { target_user.id }
@@ -27,7 +27,7 @@ FactoryBot.define do
end
trait :project_event do
- transient { target_project { create(:project) } }
+ transient { target_project { association(:project) } }
entity_type { 'Project' }
entity_id { target_project.id }
@@ -50,7 +50,7 @@ FactoryBot.define do
end
trait :group_event do
- transient { target_group { create(:group) } }
+ transient { target_group { association(:group) } }
entity_type { 'Group' }
entity_id { target_group.id }
diff --git a/spec/factories/bulk_import.rb b/spec/factories/bulk_import.rb
index 0231fe7cfef..07907bab3df 100644
--- a/spec/factories/bulk_import.rb
+++ b/spec/factories/bulk_import.rb
@@ -4,5 +4,21 @@ FactoryBot.define do
factory :bulk_import, class: 'BulkImport' do
user
source_type { :gitlab }
+
+ trait :created do
+ status { 0 }
+ end
+
+ trait :started do
+ status { 1 }
+ end
+
+ trait :finished do
+ status { 2 }
+ end
+
+ trait :failed do
+ status { -1 }
+ end
end
end
diff --git a/spec/factories/bulk_import/entities.rb b/spec/factories/bulk_import/entities.rb
index 3bf6af92d00..cf31ffec4f6 100644
--- a/spec/factories/bulk_import/entities.rb
+++ b/spec/factories/bulk_import/entities.rb
@@ -17,5 +17,25 @@ FactoryBot.define do
trait(:project_entity) do
source_type { :project_entity }
end
+
+ trait :created do
+ status { 0 }
+ end
+
+ trait :started do
+ status { 1 }
+
+ sequence(:jid) { |n| "bulk_import_entity_#{n}" }
+ end
+
+ trait :finished do
+ status { 2 }
+
+ sequence(:jid) { |n| "bulk_import_entity_#{n}" }
+ end
+
+ trait :failed do
+ status { -1 }
+ end
end
end
diff --git a/spec/factories/bulk_import/trackers.rb b/spec/factories/bulk_import/trackers.rb
new file mode 100644
index 00000000000..7a1fa0849fc
--- /dev/null
+++ b/spec/factories/bulk_import/trackers.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :bulk_import_tracker, class: 'BulkImports::Tracker' do
+ association :entity, factory: :bulk_import_entity
+
+ relation { :relation }
+ has_next_page { false }
+ end
+end
diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb
index 73920b76025..11719e40cf2 100644
--- a/spec/factories/ci/builds.rb
+++ b/spec/factories/ci/builds.rb
@@ -332,6 +332,18 @@ FactoryBot.define do
end
end
+ trait :test_reports_with_duplicate_failed_test_names do
+ after(:build) do |build|
+ build.job_artifacts << create(:ci_job_artifact, :junit_with_duplicate_failed_test_names, job: build)
+ end
+ end
+
+ trait :test_reports_with_three_failures do
+ after(:build) do |build|
+ build.job_artifacts << create(:ci_job_artifact, :junit_with_three_failures, job: build)
+ end
+ end
+
trait :accessibility_reports do
after(:build) do |build|
build.job_artifacts << create(:ci_job_artifact, :accessibility, job: build)
@@ -492,10 +504,21 @@ FactoryBot.define do
failure_reason { 10 }
end
+ trait :forward_deployment_failure do
+ failed
+ failure_reason { 13 }
+ end
+
trait :with_runner_session do
after(:build) do |build|
build.build_runner_session(url: 'https://localhost')
end
end
+
+ trait :interruptible do
+ after(:build) do |build|
+ build.metadata.interruptible = true
+ end
+ end
end
end
diff --git a/spec/factories/ci/daily_build_group_report_results.rb b/spec/factories/ci/daily_build_group_report_results.rb
index 8653316b51a..d836ee9567c 100644
--- a/spec/factories/ci/daily_build_group_report_results.rb
+++ b/spec/factories/ci/daily_build_group_report_results.rb
@@ -3,12 +3,18 @@
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 }
+ date { Date.current }
project
last_pipeline factory: :ci_pipeline
group_name { 'rspec' }
data do
{ 'coverage' => 77.0 }
end
+ default_branch { true }
+
+ trait :on_feature_branch do
+ ref_path { Gitlab::Git::BRANCH_REF_PREFIX + 'feature' }
+ default_branch { false }
+ end
end
end
diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb
index 1bd4b2826c4..223184891b7 100644
--- a/spec/factories/ci/job_artifacts.rb
+++ b/spec/factories/ci/job_artifacts.rb
@@ -109,6 +109,16 @@ FactoryBot.define do
end
end
+ trait :junit_with_duplicate_failed_test_names do
+ file_type { :junit }
+ file_format { :gzip }
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/junit/junit_with_duplicate_failed_test_names.xml.gz'), 'application/x-gzip')
+ end
+ end
+
trait :junit_with_ant do
file_type { :junit }
file_format { :gzip }
@@ -139,6 +149,16 @@ FactoryBot.define do
end
end
+ trait :junit_with_three_failures do
+ file_type { :junit }
+ file_format { :gzip }
+
+ after(:build) do |artifact, evaluator|
+ artifact.file = fixture_file_upload(
+ Rails.root.join('spec/fixtures/junit/junit_with_three_failures.xml.gz'), 'application/x-gzip')
+ end
+ end
+
trait :accessibility do
file_type { :accessibility }
file_format { :raw }
diff --git a/spec/factories/ci/pipelines.rb b/spec/factories/ci/pipelines.rb
index 4fa5dde4eff..14bd0ab1bc6 100644
--- a/spec/factories/ci/pipelines.rb
+++ b/spec/factories/ci/pipelines.rb
@@ -121,6 +121,14 @@ FactoryBot.define do
end
end
+ trait :with_test_reports_with_three_failures do
+ status { :success }
+
+ after(:build) do |pipeline, _evaluator|
+ pipeline.builds << build(:ci_build, :test_reports_with_three_failures, pipeline: pipeline, project: pipeline.project)
+ end
+ end
+
trait :with_accessibility_reports do
status { :success }
diff --git a/spec/factories/ci/reports/test_case.rb b/spec/factories/ci/reports/test_case.rb
new file mode 100644
index 00000000000..0626de9d6dc
--- /dev/null
+++ b/spec/factories/ci/reports/test_case.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :report_test_case, class: 'Gitlab::Ci::Reports::TestCase' do
+ suite_name { "rspec" }
+ name { "test-1" }
+ classname { "trace" }
+ file { "spec/trace_spec.rb" }
+ execution_time { 1.23 }
+ status { Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS }
+ system_output { nil }
+ attachment { nil }
+ association :job, factory: :ci_build
+
+ trait :failed do
+ status { Gitlab::Ci::Reports::TestCase::STATUS_FAILED }
+ system_output { "Failure/Error: is_expected.to eq(300) expected: 300 got: -100" }
+ end
+
+ trait :failed_with_attachment do
+ status { Gitlab::Ci::Reports::TestCase::STATUS_FAILED }
+ attachment { "some/path.png" }
+ end
+
+ skip_create
+
+ initialize_with do
+ new(
+ suite_name: suite_name,
+ name: name,
+ classname: classname,
+ file: file,
+ execution_time: execution_time,
+ status: status,
+ system_output: system_output,
+ attachment: attachment,
+ job: job
+ )
+ end
+ end
+end
diff --git a/spec/factories/ci/test_case.rb b/spec/factories/ci/test_case.rb
index 7f99f0e123e..601a3fae970 100644
--- a/spec/factories/ci/test_case.rb
+++ b/spec/factories/ci/test_case.rb
@@ -1,41 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :test_case, class: 'Gitlab::Ci::Reports::TestCase' do
- suite_name { "rspec" }
- name { "test-1" }
- classname { "trace" }
- file { "spec/trace_spec.rb" }
- execution_time { 1.23 }
- status { Gitlab::Ci::Reports::TestCase::STATUS_SUCCESS }
- system_output { nil }
- attachment { nil }
- association :job, factory: :ci_build
-
- trait :failed do
- status { Gitlab::Ci::Reports::TestCase::STATUS_FAILED }
- system_output { "Failure/Error: is_expected.to eq(300) expected: 300 got: -100" }
- end
-
- trait :failed_with_attachment do
- status { Gitlab::Ci::Reports::TestCase::STATUS_FAILED }
- attachment { "some/path.png" }
- end
-
- skip_create
-
- initialize_with do
- new(
- suite_name: suite_name,
- name: name,
- classname: classname,
- file: file,
- execution_time: execution_time,
- status: status,
- system_output: system_output,
- attachment: attachment,
- job: job
- )
- end
+ factory :ci_test_case, class: 'Ci::TestCase' do
+ project
+ key_hash { Digest::SHA256.hexdigest(SecureRandom.hex) }
end
end
diff --git a/spec/factories/ci/test_case_failure.rb b/spec/factories/ci/test_case_failure.rb
new file mode 100644
index 00000000000..11fb002804b
--- /dev/null
+++ b/spec/factories/ci/test_case_failure.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :ci_test_case_failure, class: 'Ci::TestCaseFailure' do
+ build factory: :ci_build
+ test_case factory: :ci_test_case
+ failed_at { Time.current }
+ end
+end
diff --git a/spec/factories/clusters/applications/helm.rb b/spec/factories/clusters/applications/helm.rb
index 46aa640780b..01df5cc677d 100644
--- a/spec/factories/clusters/applications/helm.rb
+++ b/spec/factories/clusters/applications/helm.rb
@@ -5,7 +5,7 @@ FactoryBot.define do
cluster factory: %i(cluster provided_by_gcp)
before(:create) do
- allow(Gitlab::Kubernetes::Helm::Certificate).to receive(:generate_root)
+ allow(Gitlab::Kubernetes::Helm::V2::Certificate).to receive(:generate_root)
.and_return(
double(
key_string: File.read(Rails.root.join('spec/fixtures/clusters/sample_key.key')),
@@ -15,7 +15,7 @@ FactoryBot.define do
end
after(:create) do
- allow(Gitlab::Kubernetes::Helm::Certificate).to receive(:generate_root).and_call_original
+ allow(Gitlab::Kubernetes::Helm::V2::Certificate).to receive(:generate_root).and_call_original
end
trait :not_installable do
diff --git a/spec/factories/container_repositories.rb b/spec/factories/container_repositories.rb
index 4cf1537f64b..86bb129067f 100644
--- a/spec/factories/container_repositories.rb
+++ b/spec/factories/container_repositories.rb
@@ -13,6 +13,26 @@ FactoryBot.define do
name { '' }
end
+ trait :status_delete_scheduled do
+ status { :delete_scheduled }
+ end
+
+ trait :status_delete_failed do
+ status { :delete_failed }
+ end
+
+ trait :cleanup_scheduled do
+ expiration_policy_cleanup_status { :cleanup_scheduled }
+ end
+
+ trait :cleanup_unfinished do
+ expiration_policy_cleanup_status { :cleanup_unfinished }
+ end
+
+ trait :cleanup_ongoing do
+ expiration_policy_cleanup_status { :cleanup_ongoing }
+ end
+
after(:build) do |repository, evaluator|
next if evaluator.tags.to_a.none?
diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb
index 2d185794ac9..ba1ae11c18d 100644
--- a/spec/factories/custom_emoji.rb
+++ b/spec/factories/custom_emoji.rb
@@ -4,6 +4,7 @@ FactoryBot.define do
factory :custom_emoji, class: 'CustomEmoji' do
sequence(:name) { |n| "custom_emoji#{n}" }
namespace
- file { fixture_file_upload(Rails.root.join('spec/fixtures/dk.png')) }
+ group
+ file { 'https://gitlab.com/images/partyparrot.png' }
end
end
diff --git a/spec/factories/dependency_proxy.rb b/spec/factories/dependency_proxy.rb
new file mode 100644
index 00000000000..5d763392a99
--- /dev/null
+++ b/spec/factories/dependency_proxy.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :dependency_proxy_blob, class: 'DependencyProxy::Blob' do
+ group
+ file { fixture_file_upload('spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz') }
+ file_name { 'a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz' }
+ end
+end
diff --git a/spec/factories/design_management/design_at_version.rb b/spec/factories/design_management/design_at_version.rb
index b73df71595c..3d85269ee27 100644
--- a/spec/factories/design_management/design_at_version.rb
+++ b/spec/factories/design_management/design_at_version.rb
@@ -9,13 +9,13 @@ FactoryBot.define do
version { nil }
transient do
- issue { design&.issue || version&.issue || create(:issue) }
+ issue { design&.issue || version&.issue || association(:issue) }
end
initialize_with do
attrs = attributes.dup
- attrs[:design] ||= create(:design, issue: issue)
- attrs[:version] ||= create(:design_version, issue: issue)
+ attrs[:design] ||= association(:design, issue: issue)
+ attrs[:version] ||= association(:design_version, issue: issue)
new(attrs)
end
diff --git a/spec/factories/design_management/designs.rb b/spec/factories/design_management/designs.rb
index 38d0545483c..c4fb330a0da 100644
--- a/spec/factories/design_management/designs.rb
+++ b/spec/factories/design_management/designs.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
- factory :design, class: 'DesignManagement::Design' do
- issue { create(:issue) }
- project { issue&.project || create(:project) }
+ factory :design, traits: [:has_internal_id], class: 'DesignManagement::Design' do
+ issue { association(:issue) }
+ project { issue&.project || association(:project) }
sequence(:filename) { |n| "homescreen-#{n}.jpg" }
transient do
diff --git a/spec/factories/design_management/versions.rb b/spec/factories/design_management/versions.rb
index a5c0e7076e9..0233a3b567d 100644
--- a/spec/factories/design_management/versions.rb
+++ b/spec/factories/design_management/versions.rb
@@ -3,8 +3,8 @@
FactoryBot.define do
factory :design_version, class: 'DesignManagement::Version' do
sha
- issue { designs.first&.issue || create(:issue) }
- author { issue&.author || create(:user) }
+ issue { designs.first&.issue || association(:issue) }
+ author { issue&.author || association(:user) }
transient do
designs_count { 1 }
diff --git a/spec/factories/import_configurations.rb b/spec/factories/import_configurations.rb
new file mode 100644
index 00000000000..1686d1b0d75
--- /dev/null
+++ b/spec/factories/import_configurations.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :bulk_import_configuration, class: 'BulkImports::Configuration' do
+ association :bulk_import, factory: :bulk_import
+
+ url { 'https://gitlab.example' }
+ access_token { 'token' }
+ end
+end
diff --git a/spec/factories/issues/csv_import.rb b/spec/factories/issues/csv_import.rb
new file mode 100644
index 00000000000..94688cf6232
--- /dev/null
+++ b/spec/factories/issues/csv_import.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :issue_csv_import, class: 'Issues::CsvImport' do
+ project
+ user
+ end
+end
diff --git a/spec/factories/merge_request_cleanup_schedules.rb b/spec/factories/merge_request_cleanup_schedules.rb
new file mode 100644
index 00000000000..a89d0c88731
--- /dev/null
+++ b/spec/factories/merge_request_cleanup_schedules.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :merge_request_cleanup_schedule, class: 'MergeRequest::CleanupSchedule' do
+ merge_request
+ scheduled_at { Time.current }
+ end
+end
diff --git a/spec/factories/packages.rb b/spec/factories/packages.rb
index e2c5b000988..73870a28b89 100644
--- a/spec/factories/packages.rb
+++ b/spec/factories/packages.rb
@@ -129,7 +129,7 @@ FactoryBot.define do
end
trait(:without_loaded_metadatum) do
- conan_metadatum { build(:conan_metadatum, package: nil) }
+ conan_metadatum { build(:conan_metadatum, package: nil) } # rubocop:disable FactoryBot/InlineAssociation
end
end
@@ -141,16 +141,12 @@ FactoryBot.define do
end
factory :composer_metadatum, class: 'Packages::Composer::Metadatum' do
- package { create(:composer_package) }
+ package { association(:composer_package) }
target_sha { '123' }
composer_json { { name: 'foo' } }
end
- factory :package_build_info, class: 'Packages::BuildInfo' do
- package
- end
-
factory :maven_metadatum, class: 'Packages::Maven::Metadatum' do
association :package, package_type: :maven
path { 'my/company/app/my-app/1.0-SNAPSHOT' }
@@ -166,12 +162,12 @@ FactoryBot.define do
end
factory :pypi_metadatum, class: 'Packages::Pypi::Metadatum' do
- package { create(:pypi_package, without_loaded_metadatum: true) }
+ package { association(:pypi_package, without_loaded_metadatum: true) }
required_python { '>=2.7' }
end
factory :nuget_metadatum, class: 'Packages::Nuget::Metadatum' do
- package { create(:nuget_package) }
+ package { association(:nuget_package) }
license_url { 'http://www.gitlab.com' }
project_url { 'http://www.gitlab.com' }
@@ -179,7 +175,7 @@ FactoryBot.define do
end
factory :conan_file_metadatum, class: 'Packages::Conan::FileMetadatum' do
- package_file { create(:conan_package_file, :conan_recipe_file, without_loaded_metadatum: true) }
+ package_file { association(:conan_package_file, :conan_recipe_file, without_loaded_metadatum: true) }
recipe_revision { '0' }
conan_file_type { 'recipe_file' }
@@ -188,7 +184,7 @@ FactoryBot.define do
end
trait(:package_file) do
- package_file { create(:conan_package_file, :conan_package, without_loaded_metadatum: true) }
+ package_file { association(:conan_package_file, :conan_package, without_loaded_metadatum: true) }
conan_file_type { 'package_file' }
package_revision { '0' }
conan_package_reference { '123456789' }
@@ -201,8 +197,8 @@ FactoryBot.define do
end
factory :packages_dependency_link, class: 'Packages::DependencyLink' do
- package { create(:nuget_package) }
- dependency { create(:packages_dependency) }
+ package { association(:nuget_package) }
+ dependency { association(:packages_dependency) }
dependency_type { :dependencies }
trait(:with_nuget_metadatum) do
@@ -213,7 +209,7 @@ FactoryBot.define do
end
factory :nuget_dependency_link_metadatum, class: 'Packages::Nuget::DependencyLinkMetadatum' do
- dependency_link { create(:packages_dependency_link) }
+ dependency_link { association(:packages_dependency_link) }
target_framework { '.NETStandard2.0' }
end
diff --git a/spec/factories/packages/build_info.rb b/spec/factories/packages/build_info.rb
new file mode 100644
index 00000000000..dc6208d72a9
--- /dev/null
+++ b/spec/factories/packages/build_info.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :package_build_info, class: 'Packages::BuildInfo' do
+ package
+
+ trait :with_pipeline do
+ association :pipeline, factory: [:ci_pipeline, :with_job]
+ end
+ end
+end
diff --git a/spec/factories/packages/package_file.rb b/spec/factories/packages/package_file.rb
index bcca48fb086..643ab8e4f95 100644
--- a/spec/factories/packages/package_file.rb
+++ b/spec/factories/packages/package_file.rb
@@ -15,7 +15,7 @@ FactoryBot.define do
end
factory :conan_package_file do
- package { create(:conan_package, without_package_files: true) }
+ package { association(:conan_package, without_package_files: true) }
transient do
without_loaded_metadatum { false }
diff --git a/spec/factories/packages/package_file_build_infos.rb b/spec/factories/packages/package_file_build_infos.rb
new file mode 100644
index 00000000000..f83c32f4a1a
--- /dev/null
+++ b/spec/factories/packages/package_file_build_infos.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :package_file_build_info, class: 'Packages::PackageFileBuildInfo' do
+ package_file
+
+ trait :with_pipeline do
+ association :pipeline, factory: [:ci_pipeline, :with_job]
+ end
+ end
+end
diff --git a/spec/factories/pages_deployments.rb b/spec/factories/pages_deployments.rb
index f57852a8f94..56aab4fa9f3 100644
--- a/spec/factories/pages_deployments.rb
+++ b/spec/factories/pages_deployments.rb
@@ -5,9 +5,13 @@ FactoryBot.define do
project
after(:build) do |deployment, _evaluator|
- deployment.file = fixture_file_upload(
- Rails.root.join("spec/fixtures/pages.zip")
- )
+ filepath = Rails.root.join("spec/fixtures/pages.zip")
+
+ deployment.file = fixture_file_upload(filepath)
+ deployment.file_sha256 = Digest::SHA256.file(filepath).hexdigest
+ ::Zip::File.open(filepath) do |zip_archive|
+ deployment.file_count = zip_archive.count
+ end
end
end
end
diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb
index 15b240acba4..88c06b3857a 100644
--- a/spec/factories/project_hooks.rb
+++ b/spec/factories/project_hooks.rb
@@ -22,6 +22,8 @@ FactoryBot.define do
pipeline_events { true }
wiki_page_events { true }
deployment_events { true }
+ feature_flag_events { true }
+ releases_events { true }
end
end
end
diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb
index ea003b67db0..ee2ad507c2d 100644
--- a/spec/factories/project_statistics.rb
+++ b/spec/factories/project_statistics.rb
@@ -23,6 +23,7 @@ FactoryBot.define do
project_statistics.packages_size = evaluator.size_multiplier * 5
project_statistics.snippets_size = evaluator.size_multiplier * 6
project_statistics.pipeline_artifacts_size = evaluator.size_multiplier * 7
+ project_statistics.uploads_size = evaluator.size_multiplier * 8
end
end
end
diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb
index 87e4a8e355d..639fff06cec 100644
--- a/spec/factories/projects.rb
+++ b/spec/factories/projects.rb
@@ -10,8 +10,10 @@ FactoryBot.define do
factory :project, class: 'Project' do
sequence(:name) { |n| "project#{n}" }
path { name.downcase.gsub(/\s/, '_') }
- # Behaves differently to nil due to cache_has_external_issue_tracker
+
+ # Behaves differently to nil due to cache_has_external_* methods.
has_external_issue_tracker { false }
+ has_external_wiki { false }
# Associations
namespace
diff --git a/spec/factories/protected_branches/push_access_levels.rb b/spec/factories/protected_branches/push_access_levels.rb
index fa3a35fe282..b735bcdcc04 100644
--- a/spec/factories/protected_branches/push_access_levels.rb
+++ b/spec/factories/protected_branches/push_access_levels.rb
@@ -3,6 +3,7 @@
FactoryBot.define do
factory :protected_branch_push_access_level, class: 'ProtectedBranch::PushAccessLevel' do
protected_branch
+ deploy_key { nil }
access_level { Gitlab::Access::DEVELOPER }
end
end
diff --git a/spec/factories/resource_label_events.rb b/spec/factories/resource_label_events.rb
index b59da465fc3..35c732fed0f 100644
--- a/spec/factories/resource_label_events.rb
+++ b/spec/factories/resource_label_events.rb
@@ -4,7 +4,7 @@ FactoryBot.define do
factory :resource_label_event do
action { :add }
label
- user { issuable&.author || create(:user) }
+ user { issuable&.author || association(:user) }
after(:build) do |event, evaluator|
event.issue = create(:issue) unless event.issuable
diff --git a/spec/factories/resource_milestone_event.rb b/spec/factories/resource_milestone_event.rb
index 86c54f2be68..a3944e013da 100644
--- a/spec/factories/resource_milestone_event.rb
+++ b/spec/factories/resource_milestone_event.rb
@@ -2,11 +2,11 @@
FactoryBot.define do
factory :resource_milestone_event do
- issue { merge_request.nil? ? create(:issue) : nil }
+ issue { merge_request.nil? ? association(:issue) : nil }
merge_request { nil }
milestone
action { :add }
state { :opened }
- user { issue&.author || merge_request&.author || create(:user) }
+ user { issue&.author || merge_request&.author || association(:user) }
end
end
diff --git a/spec/factories/resource_state_event.rb b/spec/factories/resource_state_event.rb
index e3de462b797..926c6dd8cbc 100644
--- a/spec/factories/resource_state_event.rb
+++ b/spec/factories/resource_state_event.rb
@@ -2,9 +2,9 @@
FactoryBot.define do
factory :resource_state_event do
- issue { merge_request.nil? ? create(:issue) : nil }
+ issue { merge_request.nil? ? association(:issue) : nil }
merge_request { nil }
state { :opened }
- user { issue&.author || merge_request&.author || create(:user) }
+ user { issue&.author || merge_request&.author || association(:user) }
end
end
diff --git a/spec/factories/serverless/domain.rb b/spec/factories/serverless/domain.rb
index 7a6a048fb34..c09af068d19 100644
--- a/spec/factories/serverless/domain.rb
+++ b/spec/factories/serverless/domain.rb
@@ -3,8 +3,8 @@
FactoryBot.define do
factory :serverless_domain, class: '::Serverless::Domain' do
function_name { 'test-function' }
- serverless_domain_cluster { create(:serverless_domain_cluster) }
- environment { create(:environment) }
+ serverless_domain_cluster { association(:serverless_domain_cluster) }
+ environment { association(:environment) }
skip_create
end
diff --git a/spec/factories/serverless/domain_cluster.rb b/spec/factories/serverless/domain_cluster.rb
index 40e0ecad5ad..e8ff6cf42b2 100644
--- a/spec/factories/serverless/domain_cluster.rb
+++ b/spec/factories/serverless/domain_cluster.rb
@@ -2,9 +2,9 @@
FactoryBot.define do
factory :serverless_domain_cluster, class: '::Serverless::DomainCluster' do
- pages_domain { create(:pages_domain) }
- knative { create(:clusters_applications_knative) }
- creator { create(:user) }
+ pages_domain { association(:pages_domain) }
+ knative { association(:clusters_applications_knative) }
+ creator { association(:user) }
certificate do
File.read(Rails.root.join('spec/fixtures/', 'ssl_certificate.pem'))
diff --git a/spec/factories/services.rb b/spec/factories/services.rb
index 13997080817..1935ace8e96 100644
--- a/spec/factories/services.rb
+++ b/spec/factories/services.rb
@@ -56,6 +56,10 @@ FactoryBot.define do
trait :inactive do
active { false }
end
+
+ before(:create) do |service|
+ service.data = build(:alerts_service_data, service: service)
+ end
end
factory :drone_ci_service do
@@ -79,6 +83,8 @@ FactoryBot.define do
jira_issue_transition_id { '56-1' }
issues_enabled { false }
project_key { nil }
+ vulnerabilities_enabled { false }
+ vulnerabilities_issuetype { nil }
end
before(:create) do |service, evaluator|
@@ -86,7 +92,8 @@ FactoryBot.define do
create(:jira_tracker_data, service: service,
url: evaluator.url, api_url: evaluator.api_url, jira_issue_transition_id: evaluator.jira_issue_transition_id,
username: evaluator.username, password: evaluator.password, issues_enabled: evaluator.issues_enabled,
- project_key: evaluator.project_key
+ project_key: evaluator.project_key, vulnerabilities_enabled: evaluator.vulnerabilities_enabled,
+ vulnerabilities_issuetype: evaluator.vulnerabilities_issuetype
)
end
end
@@ -139,6 +146,13 @@ FactoryBot.define do
end
end
+ factory :external_wiki_service do
+ project
+ type { ExternalWikiService }
+ active { true }
+ external_wiki_url { 'http://external-wiki-url.com' }
+ end
+
factory :open_project_service do
project
active { true }
diff --git a/spec/factories/terraform/state.rb b/spec/factories/terraform/state.rb
index d80c1315e28..c54a8aedbc6 100644
--- a/spec/factories/terraform/state.rb
+++ b/spec/factories/terraform/state.rb
@@ -2,7 +2,7 @@
FactoryBot.define do
factory :terraform_state, class: 'Terraform::State' do
- project { create(:project) }
+ project { association(:project) }
sequence(:name) { |n| "state-#{n}" }
@@ -14,12 +14,12 @@ FactoryBot.define do
trait :locked do
sequence(:lock_xid) { |n| "lock-#{n}" }
locked_at { Time.current }
- locked_by_user { create(:user) }
+ locked_by_user { association(:user) }
end
trait :with_version do
after(:create) do |state|
- create(:terraform_state_version, :with_file, terraform_state: state)
+ create(:terraform_state_version, terraform_state: state)
end
end
diff --git a/spec/factories/terraform/state_version.rb b/spec/factories/terraform/state_version.rb
index b45bd01fd3c..c6bd08815cf 100644
--- a/spec/factories/terraform/state_version.rb
+++ b/spec/factories/terraform/state_version.rb
@@ -4,6 +4,7 @@ FactoryBot.define do
factory :terraform_state_version, class: 'Terraform::StateVersion' do
terraform_state factory: :terraform_state
created_by_user factory: :user
+ build { association(:ci_build, project: terraform_state.project) }
sequence(:version)
file { fixture_file_upload('spec/fixtures/terraform/terraform.tfstate', 'application/json') }
diff --git a/spec/factories/uploads.rb b/spec/factories/uploads.rb
index b19af277cc3..85237e2d791 100644
--- a/spec/factories/uploads.rb
+++ b/spec/factories/uploads.rb
@@ -2,7 +2,7 @@
FactoryBot.define do
factory :upload do
- model { create(:project) }
+ model { association(:project) }
size { 100.kilobytes }
uploader { "AvatarUploader" }
mount_point { :avatar }
@@ -20,7 +20,7 @@ FactoryBot.define do
end
trait :personal_snippet_upload do
- model { create(:personal_snippet) }
+ model { association(:personal_snippet) }
path { File.join(secret, filename) }
uploader { "PersonalFileUploader" }
secret { SecureRandom.hex }
@@ -46,7 +46,7 @@ FactoryBot.define do
end
trait :namespace_upload do
- model { create(:group) }
+ model { association(:group) }
path { File.join(secret, filename) }
uploader { "NamespaceFileUploader" }
secret { SecureRandom.hex }
@@ -54,7 +54,7 @@ FactoryBot.define do
end
trait :favicon_upload do
- model { create(:appearance) }
+ model { association(:appearance) }
uploader { "FaviconUploader" }
secret { SecureRandom.hex }
mount_point { :favicon }
@@ -62,13 +62,13 @@ FactoryBot.define do
trait :attachment_upload do
mount_point { :attachment }
- model { create(:note) }
+ model { association(:note) }
uploader { "AttachmentUploader" }
end
trait :design_action_image_v432x230_upload do
mount_point { :image_v432x230 }
- model { create(:design_action) }
+ model { association(:design_action) }
uploader { ::DesignManagement::DesignV432x230Uploader.name }
end
end
diff --git a/spec/factories/usage_data.rb b/spec/factories/usage_data.rb
index adca6eabb0e..87f806a3d74 100644
--- a/spec/factories/usage_data.rb
+++ b/spec/factories/usage_data.rb
@@ -52,6 +52,11 @@ FactoryBot.define do
create(:protected_branch, project: projects[0])
create(:protected_branch, name: 'main', project: projects[0])
+ # Alert Management
+ create(:alert_management_http_integration, project: projects[0], name: 'DataDog')
+ create(:alert_management_http_integration, project: projects[0], name: 'DataCat')
+ create(:alert_management_http_integration, :inactive, project: projects[1], name: 'DataFox')
+
# Tracing
create(:project_tracing_setting, project: projects[0])
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 2e5b3be3bf2..1b430009ab5 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -66,7 +66,7 @@ FactoryBot.define do
trait :with_sign_ins do
sign_in_count { 3 }
- current_sign_in_at { Time.now }
+ current_sign_in_at { FFaker::Time.between(10.days.ago, 1.day.ago) }
last_sign_in_at { FFaker::Time.between(10.days.ago, 1.day.ago) }
current_sign_in_ip { '127.0.0.1' }
last_sign_in_ip { '127.0.0.1' }
diff --git a/spec/factories/wiki_pages.rb b/spec/factories/wiki_pages.rb
index 3397277839e..6f912a183e8 100644
--- a/spec/factories/wiki_pages.rb
+++ b/spec/factories/wiki_pages.rb
@@ -39,14 +39,14 @@ FactoryBot.define do
factory :wiki_page_meta, class: 'WikiPage::Meta' do
title { generate(:wiki_page_title) }
- project { create(:project) }
+ project { association(:project) }
trait :for_wiki_page do
transient do
- wiki_page { create(:wiki_page, container: project) }
+ wiki_page { association(:wiki_page, container: project) }
end
- project { @overrides[:wiki_page]&.container || create(:project) }
+ project { @overrides[:wiki_page]&.container || association(:project) }
title { wiki_page.title }
initialize_with do
@@ -58,7 +58,7 @@ FactoryBot.define do
end
factory :wiki_page_slug, class: 'WikiPage::Slug' do
- wiki_page_meta { create(:wiki_page_meta) }
+ wiki_page_meta { association(:wiki_page_meta) }
slug { generate(:sluggified_title) }
canonical { false }
diff --git a/spec/factories/wikis.rb b/spec/factories/wikis.rb
index 86d98bfd756..05f6fb0de58 100644
--- a/spec/factories/wikis.rb
+++ b/spec/factories/wikis.rb
@@ -17,5 +17,9 @@ FactoryBot.define do
container { project }
end
+
+ trait :empty_repo do
+ after(:create, &:create_wiki_repository)
+ end
end
end
diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb
index cbab809cddb..2f0bcd318d9 100644
--- a/spec/fast_spec_helper.rb
+++ b/spec/fast_spec_helper.rb
@@ -1,5 +1,12 @@
# frozen_string_literal: true
+# $" is $LOADED_FEATURES, but RuboCop didn't like it
+if $".include?(File.expand_path('spec_helper.rb', __dir__))
+ # There's no need to load anything here if spec_helper is already loaded
+ # because spec_helper is more extensive than fast_spec_helper
+ return
+end
+
require 'bundler/setup'
ENV['GITLAB_ENV'] = 'test'
diff --git a/spec/features/admin/admin_builds_spec.rb b/spec/features/admin/admin_builds_spec.rb
index 85f0c44ed9c..166fde0f37a 100644
--- a/spec/features/admin/admin_builds_spec.rb
+++ b/spec/features/admin/admin_builds_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe 'Admin Builds' do
context 'All tab' do
context 'when have jobs' do
- it 'shows all jobs' do
+ it 'shows all jobs', :js do
create(:ci_build, pipeline: pipeline, status: :pending)
create(:ci_build, pipeline: pipeline, status: :running)
create(:ci_build, pipeline: pipeline, status: :success)
@@ -24,6 +24,10 @@ RSpec.describe 'Admin Builds' do
expect(page).to have_selector('.row-content-block', text: 'All jobs')
expect(page.all('.build-link').size).to eq(4)
expect(page).to have_button 'Stop all jobs'
+
+ click_button 'Stop all jobs'
+ expect(page).to have_button 'Stop jobs'
+ expect(page).to have_content 'Stop all jobs?'
end
end
diff --git a/spec/features/admin/admin_dev_ops_report_spec.rb b/spec/features/admin/admin_dev_ops_report_spec.rb
index c201011cbea..3b2c9d75870 100644
--- a/spec/features/admin/admin_dev_ops_report_spec.rb
+++ b/spec/features/admin/admin_dev_ops_report_spec.rb
@@ -2,59 +2,65 @@
require 'spec_helper'
-RSpec.describe 'DevOps Report page' do
+RSpec.describe 'DevOps Report page', :js do
before do
sign_in(create(:admin))
end
- it 'has dismissable intro callout', :js do
- visit admin_dev_ops_report_path
+ context 'with devops_adoption feature flag disabled' do
+ before do
+ stub_feature_flags(devops_adoption: false)
+ end
- expect(page).to have_content 'Introducing Your DevOps Report'
+ it 'has dismissable intro callout' do
+ visit admin_dev_ops_report_path
- find('.js-close-callout').click
+ expect(page).to have_content 'Introducing Your DevOps Report'
- expect(page).not_to have_content 'Introducing Your DevOps Report'
- end
+ find('.js-close-callout').click
- context 'when usage ping is disabled' do
- before do
- stub_application_setting(usage_ping_enabled: false)
+ expect(page).not_to have_content 'Introducing Your DevOps Report'
end
- it 'shows empty state', :js do
- visit admin_dev_ops_report_path
+ context 'when usage ping is disabled' do
+ before do
+ stub_application_setting(usage_ping_enabled: false)
+ end
- expect(page).to have_selector(".js-empty-state")
- end
+ it 'shows empty state' do
+ visit admin_dev_ops_report_path
- it 'hides the intro callout' do
- visit admin_dev_ops_report_path
+ expect(page).to have_selector(".js-empty-state")
+ end
- expect(page).not_to have_content 'Introducing Your DevOps Report'
+ it 'hides the intro callout' do
+ visit admin_dev_ops_report_path
+
+ expect(page).not_to have_content 'Introducing Your DevOps Report'
+ end
end
- end
- context 'when there is no data to display' do
- it 'shows empty state' do
- stub_application_setting(usage_ping_enabled: true)
+ context 'when there is no data to display' do
+ it 'shows empty state' do
+ stub_application_setting(usage_ping_enabled: true)
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_report_path
- expect(page).to have_content('Data is still calculating')
+ expect(page).to have_content('Data is still calculating')
+ end
end
- end
- context 'when there is data to display' do
- it 'shows numbers for each metric' do
- stub_application_setting(usage_ping_enabled: true)
- create(:dev_ops_report_metric)
+ context 'when there is data to display' do
+ it 'shows numbers for each metric' do
+ stub_application_setting(usage_ping_enabled: true)
+ create(:dev_ops_report_metric)
- visit admin_dev_ops_report_path
+ visit admin_dev_ops_report_path
- expect(page).to have_content(
- 'Issues created per active user 1.2 You 9.3 Lead 13.3%'
- )
+ expect(page).to have_content(
+ 'Issues created per active user 1.2 You 9.3 Lead 13.3%'
+ )
+ end
end
end
end
diff --git a/spec/features/admin/admin_groups_spec.rb b/spec/features/admin/admin_groups_spec.rb
index 653a45a4bb8..96709cf8a12 100644
--- a/spec/features/admin/admin_groups_spec.rb
+++ b/spec/features/admin/admin_groups_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Admin Groups' do
include Select2Helper
+ include Spec::Support::Helpers::Features::MembersHelpers
let(:internal) { Gitlab::VisibilityLevel::INTERNAL }
let(:user) { create :user }
@@ -11,8 +12,6 @@ RSpec.describe 'Admin Groups' do
let!(:current_user) { create(:admin) }
before do
- stub_feature_flags(vue_group_members_list: false)
-
sign_in(current_user)
stub_application_setting(default_group_visibility: internal)
end
@@ -176,7 +175,7 @@ RSpec.describe 'Admin Groups' do
click_button 'Invite'
- page.within '[data-qa-selector="members_list"]' do
+ page.within members_table do
expect(page).to have_content(current_user.name)
expect(page).to have_content('Developer')
end
diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb
index 528dfad606e..8929abc7edc 100644
--- a/spec/features/admin/admin_settings_spec.rb
+++ b/spec/features/admin/admin_settings_spec.rb
@@ -132,32 +132,14 @@ RSpec.describe 'Admin updates settings', :clean_gitlab_redis_shared_state, :do_n
context 'Change Sign-up restrictions' do
context 'Require Admin approval for new signup setting' do
- context 'when feature is enabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: true)
- end
-
- it 'changes the setting' do
- page.within('.as-signup') do
- check 'Require admin approval for new sign-ups'
- click_button 'Save changes'
- end
-
- expect(current_settings.require_admin_approval_after_user_signup).to be_truthy
- expect(page).to have_content "Application settings saved successfully"
- end
- end
-
- context 'when feature is disabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: false)
+ it 'changes the setting' do
+ page.within('.as-signup') do
+ check 'Require admin approval for new sign-ups'
+ click_button 'Save changes'
end
- it 'does not show the the setting' do
- page.within('.as-signup') do
- expect(page).not_to have_selector('.application_setting_require_admin_approval_after_user_signup')
- end
- end
+ expect(current_settings.require_admin_approval_after_user_signup).to be_truthy
+ expect(page).to have_content "Application settings saved successfully"
end
end
end
diff --git a/spec/features/admin/admin_users_spec.rb b/spec/features/admin/admin_users_spec.rb
index e06e2d14f3c..97a30143a59 100644
--- a/spec/features/admin/admin_users_spec.rb
+++ b/spec/features/admin/admin_users_spec.rb
@@ -75,26 +75,12 @@ RSpec.describe "Admin::Users" do
end
context '`Pending approval` tab' do
- context 'feature is enabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: true)
- visit admin_users_path
- end
-
- it 'shows the `Pending approval` tab' do
- expect(page).to have_link('Pending approval', href: admin_users_path(filter: 'blocked_pending_approval'))
- end
+ before do
+ visit admin_users_path
end
- context 'feature is disabled' do
- before do
- stub_feature_flags(admin_approval_for_new_user_signups: false)
- visit admin_users_path
- end
-
- it 'does not show the `Pending approval` tab' do
- expect(page).not_to have_link('Pending approval', href: admin_users_path(filter: 'blocked_pending_approval'))
- end
+ it 'shows the `Pending approval` tab' do
+ expect(page).to have_link('Pending approval', href: admin_users_path(filter: 'blocked_pending_approval'))
end
end
end
@@ -218,6 +204,32 @@ RSpec.describe "Admin::Users" do
expect(page).to have_content(user.email)
end
end
+
+ context 'when blocking a user' do
+ it 'shows confirmation and allows blocking', :js do
+ expect(page).to have_content(user.email)
+
+ find("[data-testid='user-action-button-#{user.id}']").click
+
+ within find("[data-testid='user-action-dropdown-#{user.id}']") do
+ find('li button', text: 'Block').click
+ end
+
+ wait_for_requests
+
+ expect(page).to have_content('Block user')
+ expect(page).to have_content('Blocking user has the following effects')
+ expect(page).to have_content('User will not be able to login')
+ expect(page).to have_content('Owned groups will be left')
+
+ find('.modal-footer button', text: 'Block').click
+
+ wait_for_requests
+
+ expect(page).to have_content('Successfully blocked')
+ expect(page).not_to have_content(user.email)
+ end
+ end
end
describe "GET /admin/users/new" do
@@ -376,6 +388,26 @@ RSpec.describe "Admin::Users" do
end
end
+ context 'when blocking the user' do
+ it 'shows confirmation and allows blocking', :js do
+ visit admin_user_path(user)
+
+ find('button', text: 'Block user').click
+
+ wait_for_requests
+
+ expect(page).to have_content('Block user')
+ expect(page).to have_content('You can always unblock their account, their data will remain intact.')
+
+ find('.modal-footer button', text: 'Block').click
+
+ wait_for_requests
+
+ expect(page).to have_content('Successfully blocked')
+ expect(page).to have_content('This user is blocked')
+ end
+ end
+
describe 'Impersonation' do
let(:another_user) { create(:user) }
diff --git a/spec/features/admin/admin_uses_repository_checks_spec.rb b/spec/features/admin/admin_uses_repository_checks_spec.rb
index 44642983a36..0fb5124f673 100644
--- a/spec/features/admin/admin_uses_repository_checks_spec.rb
+++ b/spec/features/admin/admin_uses_repository_checks_spec.rb
@@ -46,7 +46,7 @@ RSpec.describe 'Admin uses repository checks', :request_store, :clean_gitlab_red
)
visit_admin_project_page(project)
- page.within('.gl-alert') do
+ page.within('[data-testid="last-repository-check-failed-alert"]') do
expect(page.text).to match(/Last repository check \(just now\) failed/)
end
end
diff --git a/spec/features/alert_management_spec.rb b/spec/features/alert_management_spec.rb
index 2989f72e356..3322c9c574f 100644
--- a/spec/features/alert_management_spec.rb
+++ b/spec/features/alert_management_spec.rb
@@ -41,21 +41,6 @@ RSpec.describe 'Alert management', :js do
expect(page).to have_content(environment.name)
end
end
-
- context 'when expose_environment_path_in_alert_details feature flag is disabled' do
- before do
- stub_feature_flags(expose_environment_path_in_alert_details: false)
- end
-
- it 'does not show the environment name' do
- visit(details_project_alert_management_path(project, alert))
-
- within('.alert-management-details-table') do
- expect(page).to have_content(alert.title)
- expect(page).not_to have_content(environment.name)
- end
- end
- end
end
end
end
diff --git a/spec/features/alerts_settings/user_views_alerts_settings_spec.rb b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
new file mode 100644
index 00000000000..0ded13ae607
--- /dev/null
+++ b/spec/features/alerts_settings/user_views_alerts_settings_spec.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Alert integrations settings form', :js do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ before_all do
+ project.add_maintainer(maintainer)
+ project.add_developer(developer)
+ end
+
+ before do
+ sign_in(maintainer)
+ end
+
+ describe 'when viewing alert integrations as a maintainer' do
+ context 'with feature flag enabled' do
+ before do
+ visit project_settings_operations_path(project, anchor: 'js-alert-management-settings')
+ wait_for_requests
+ end
+
+ it 'shows the alerts setting form title' do
+ page.within('#js-alert-management-settings') do
+ expect(find('h3')).to have_content('Alerts')
+ end
+ end
+
+ it 'shows the new alerts setting form' do
+ expect(page).to have_content('1. Select integration type')
+ end
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(http_integrations_list: false)
+
+ visit project_settings_operations_path(project, anchor: 'js-alert-management-settings')
+ wait_for_requests
+ end
+
+ it 'shows the old alerts setting form' do
+ expect(page).to have_content('Webhook URL')
+ end
+ end
+ end
+
+ describe 'when viewing alert integrations as a developer' do
+ before do
+ sign_in(developer)
+
+ visit project_settings_operations_path(project, anchor: 'js-alert-management-settings')
+ wait_for_requests
+ end
+
+ it 'shows the old alerts setting form' do
+ expect(page).not_to have_selector('.incident-management-list')
+ expect(page).not_to have_selector('#js-alert-management-settings')
+ end
+ end
+end
diff --git a/spec/features/boards/add_issues_modal_spec.rb b/spec/features/boards/add_issues_modal_spec.rb
index 00efca5d3a8..f941adca233 100644
--- a/spec/features/boards/add_issues_modal_spec.rb
+++ b/spec/features/boards/add_issues_modal_spec.rb
@@ -87,11 +87,12 @@ RSpec.describe 'Issue Boards add issue modal', :js do
end
end
- it 'shows selected issues' do
+ it 'shows selected issues tab and empty state message' do
page.within('.add-issues-modal') do
click_link 'Selected issues'
expect(page).not_to have_selector('.board-card')
+ expect(page).to have_content("Go back to Open issues and select some issues to add to your board.")
end
end
@@ -147,7 +148,7 @@ RSpec.describe 'Issue Boards add issue modal', :js do
end
end
- context 'selecing issues' do
+ context 'selecting issues' do
it 'selects single issue' do
page.within('.add-issues-modal') do
first('.board-card .board-card-number').click
@@ -206,7 +207,7 @@ RSpec.describe 'Issue Boards add issue modal', :js do
end
end
- it 'selects all that arent already selected' do
+ it "selects all that aren't already selected" do
page.within('.add-issues-modal') do
first('.board-card .board-card-number').click
diff --git a/spec/features/breadcrumbs_schema_markup_spec.rb b/spec/features/breadcrumbs_schema_markup_spec.rb
new file mode 100644
index 00000000000..30d5f40fea8
--- /dev/null
+++ b/spec/features/breadcrumbs_schema_markup_spec.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Breadcrumbs schema markup', :aggregate_failures do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, namespace: user.namespace) }
+ let_it_be(:issue) { create(:issue, project: project) }
+ let_it_be(:group) { create(:group, :public) }
+ let_it_be(:subgroup) { create(:group, :public, parent: group) }
+ let_it_be(:group_project) { create(:project, :public, namespace: subgroup) }
+
+ it 'generates the breadcrumb schema for user projects' do
+ visit project_url(project)
+
+ item_list = get_schema_content
+
+ expect(item_list.size).to eq 3
+ expect(item_list[0]['name']).to eq project.namespace.name
+ expect(item_list[0]['item']).to eq user_url(project.owner)
+
+ expect(item_list[1]['name']).to eq project.name
+ expect(item_list[1]['item']).to eq project_url(project)
+
+ expect(item_list[2]['name']).to eq 'Details'
+ expect(item_list[2]['item']).to eq project_url(project)
+ end
+
+ it 'generates the breadcrumb schema for group projects' do
+ visit project_url(group_project)
+
+ item_list = get_schema_content
+
+ expect(item_list.size).to eq 4
+ expect(item_list[0]['name']).to eq group.name
+ expect(item_list[0]['item']).to eq group_url(group)
+
+ expect(item_list[1]['name']).to eq subgroup.name
+ expect(item_list[1]['item']).to eq group_url(subgroup)
+
+ expect(item_list[2]['name']).to eq group_project.name
+ expect(item_list[2]['item']).to eq project_url(group_project)
+
+ expect(item_list[3]['name']).to eq 'Details'
+ expect(item_list[3]['item']).to eq project_url(group_project)
+ end
+
+ it 'generates the breadcrumb schema for group' do
+ visit group_url(subgroup)
+
+ item_list = get_schema_content
+
+ expect(item_list.size).to eq 3
+ expect(item_list[0]['name']).to eq group.name
+ expect(item_list[0]['item']).to eq group_url(group)
+
+ expect(item_list[1]['name']).to eq subgroup.name
+ expect(item_list[1]['item']).to eq group_url(subgroup)
+
+ expect(item_list[2]['name']).to eq 'Details'
+ expect(item_list[2]['item']).to eq group_url(subgroup)
+ end
+
+ it 'generates the breadcrumb schema for issues' do
+ visit project_issues_url(project)
+
+ item_list = get_schema_content
+
+ expect(item_list.size).to eq 3
+ expect(item_list[0]['name']).to eq project.namespace.name
+ expect(item_list[0]['item']).to eq user_url(project.owner)
+
+ expect(item_list[1]['name']).to eq project.name
+ expect(item_list[1]['item']).to eq project_url(project)
+
+ expect(item_list[2]['name']).to eq 'Issues'
+ expect(item_list[2]['item']).to eq project_issues_url(project)
+ end
+
+ it 'generates the breadcrumb schema for specific issue' do
+ visit project_issue_url(project, issue)
+
+ item_list = get_schema_content
+
+ expect(item_list.size).to eq 4
+ expect(item_list[0]['name']).to eq project.namespace.name
+ expect(item_list[0]['item']).to eq user_url(project.owner)
+
+ expect(item_list[1]['name']).to eq project.name
+ expect(item_list[1]['item']).to eq project_url(project)
+
+ expect(item_list[2]['name']).to eq 'Issues'
+ expect(item_list[2]['item']).to eq project_issues_url(project)
+
+ expect(item_list[3]['name']).to eq issue.to_reference
+ expect(item_list[3]['item']).to eq project_issue_url(project, issue)
+ end
+
+ def get_schema_content
+ content = find('script[type="application/ld+json"]', visible: false).text(:all)
+
+ expect(content).not_to be_nil
+
+ Gitlab::Json.parse(content)['itemListElement']
+ end
+end
diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb
index 5f58fa420fb..60d485d4558 100644
--- a/spec/features/calendar_spec.rb
+++ b/spec/features/calendar_spec.rb
@@ -42,7 +42,7 @@ RSpec.describe 'Contributions Calendar', :js do
"#{contributions} #{'contribution'.pluralize(contributions)}"
end
- "#{get_cell_color_selector(contributions)}[data-original-title='#{contribution_text}<br />#{date}']"
+ "#{get_cell_color_selector(contributions)}[title='#{contribution_text}<br />#{date}']"
end
def push_code_contribution
diff --git a/spec/features/callouts/registration_enabled_spec.rb b/spec/features/callouts/registration_enabled_spec.rb
new file mode 100644
index 00000000000..4055965273f
--- /dev/null
+++ b/spec/features/callouts/registration_enabled_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Registration enabled callout' do
+ let_it_be(:admin) { create(:admin) }
+ let_it_be(:non_admin) { create(:user) }
+
+ context 'when "Sign-up enabled" setting is `true`' do
+ before do
+ stub_application_setting(signup_enabled: true)
+ end
+
+ context 'when an admin is logged in' do
+ before do
+ sign_in(admin)
+ visit root_dashboard_path
+ end
+
+ it 'displays callout' do
+ expect(page).to have_content 'Open registration is enabled on your instance.'
+ expect(page).to have_link 'View setting', href: general_admin_application_settings_path(anchor: 'js-signup-settings')
+ end
+
+ context 'when callout is dismissed', :js do
+ before do
+ find('[data-testid="close-registration-enabled-callout"]').click
+
+ visit root_dashboard_path
+ end
+
+ it 'does not display callout' do
+ expect(page).not_to have_content 'Open registration is enabled on your instance.'
+ end
+ end
+ end
+
+ context 'when a non-admin is logged in' do
+ before do
+ sign_in(non_admin)
+ visit root_dashboard_path
+ end
+
+ it 'does not display callout' do
+ expect(page).not_to have_content 'Open registration is enabled on your instance.'
+ end
+ end
+ end
+end
diff --git a/spec/features/canonical_link_spec.rb b/spec/features/canonical_link_spec.rb
new file mode 100644
index 00000000000..8b64e9a5b9d
--- /dev/null
+++ b/spec/features/canonical_link_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Canonical link' do
+ include Spec::Support::Helpers::Features::CanonicalLinkHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :public, namespace: user.namespace) }
+ let_it_be(:issue) { create(:issue, project: project) }
+
+ let_it_be(:issue_request) { issue_url(issue) }
+ let_it_be(:project_request) { project_url(project) }
+
+ before do
+ sign_in(user)
+ end
+
+ shared_examples 'shows canonical link' do
+ specify do
+ visit request_url
+
+ expect(page).to have_canonical_link(expected_url)
+ end
+ end
+
+ shared_examples 'does not show canonical link' do
+ specify do
+ visit request_url
+
+ expect(page).not_to have_any_canonical_links
+ end
+ end
+
+ it_behaves_like 'does not show canonical link' do
+ let(:request_url) { issue_request }
+ end
+
+ it_behaves_like 'shows canonical link' do
+ let(:request_url) { issue_request + '/' }
+ let(:expected_url) { issue_request }
+ end
+
+ it_behaves_like 'shows canonical link' do
+ let(:request_url) { project_issues_url(project) + "/?state=opened" }
+ let(:expected_url) { project_issues_url(project, state: 'opened') }
+ end
+
+ it_behaves_like 'does not show canonical link' do
+ let(:request_url) { project_request }
+ end
+
+ it_behaves_like 'shows canonical link' do
+ let(:request_url) { project_request + '/' }
+ let(:expected_url) { project_request }
+ end
+
+ it_behaves_like 'shows canonical link' do
+ let(:query_params) { '?foo=bar' }
+ let(:request_url) { project_request + "/#{query_params}" }
+ let(:expected_url) { project_request + query_params }
+ end
+
+ # Hard-coded canonical links
+
+ it_behaves_like 'shows canonical link' do
+ let(:request_url) { explore_root_path }
+ let(:expected_url) { explore_projects_url }
+ end
+end
diff --git a/spec/features/dashboard/datetime_on_tooltips_spec.rb b/spec/features/dashboard/datetime_on_tooltips_spec.rb
index a3eacd6147c..c14a6001a3e 100644
--- a/spec/features/dashboard/datetime_on_tooltips_spec.rb
+++ b/spec/features/dashboard/datetime_on_tooltips_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe 'Tooltips on .timeago dates', :js do
end
def datetime_in_tooltip
- datetime_text = page.find('.local-timeago').text
+ datetime_text = page.find('.tooltip').text
DateTime.parse(datetime_text)
end
end
diff --git a/spec/features/dashboard/shortcuts_spec.rb b/spec/features/dashboard/shortcuts_spec.rb
index 04bbc3059de..58352518d43 100644
--- a/spec/features/dashboard/shortcuts_spec.rb
+++ b/spec/features/dashboard/shortcuts_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe 'Dashboard shortcuts', :js do
find('body').send_keys([:shift, 'P'])
find('.nothing-here-block')
- expect(page).to have_content("This user doesn't have any personal projects")
+ expect(page).to have_content('Explore public groups to find projects to contribute to.')
end
end
diff --git a/spec/features/discussion_comments/merge_request_spec.rb b/spec/features/discussion_comments/merge_request_spec.rb
index 43801b30608..761cc7ae796 100644
--- a/spec/features/discussion_comments/merge_request_spec.rb
+++ b/spec/features/discussion_comments/merge_request_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Thread Comments Merge Request', :js do
let(:merge_request) { create(:merge_request, source_project: project) }
before do
+ stub_feature_flags(remove_resolve_note: false)
+
project.add_maintainer(user)
sign_in(user)
diff --git a/spec/features/expand_collapse_diffs_spec.rb b/spec/features/expand_collapse_diffs_spec.rb
index 49343cc7a57..0912df22924 100644
--- a/spec/features/expand_collapse_diffs_spec.rb
+++ b/spec/features/expand_collapse_diffs_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'Expand and collapse diffs', :js do
before do
stub_feature_flags(increased_diff_limits: false)
+ allow(Gitlab::CurrentSettings).to receive(:diff_max_patch_bytes).and_return(100.kilobytes)
+
sign_in(create(:admin))
# Ensure that undiffable.md is in .gitattributes
diff --git a/spec/features/explore/user_explores_projects_spec.rb b/spec/features/explore/user_explores_projects_spec.rb
index e217638f62b..bf4d6c946e1 100644
--- a/spec/features/explore/user_explores_projects_spec.rb
+++ b/spec/features/explore/user_explores_projects_spec.rb
@@ -3,65 +3,112 @@
require 'spec_helper'
RSpec.describe 'User explores projects' do
- let_it_be(:archived_project) { create(:project, :archived) }
- let_it_be(:internal_project) { create(:project, :internal) }
- let_it_be(:private_project) { create(:project, :private) }
- let_it_be(:public_project) { create(:project, :public) }
-
- context 'when not signed in' do
- context 'when viewing public projects' do
- before do
- visit(explore_projects_path)
+ context 'when some projects exist' do
+ let_it_be(:archived_project) { create(:project, :archived) }
+ let_it_be(:internal_project) { create(:project, :internal) }
+ let_it_be(:private_project) { create(:project, :private) }
+ let_it_be(:public_project) { create(:project, :public) }
+
+ context 'when not signed in' do
+ context 'when viewing public projects' do
+ before do
+ visit(explore_projects_path)
+ end
+
+ include_examples 'shows public projects'
end
- include_examples 'shows public projects'
+ context 'when visibility is restricted to public' do
+ before do
+ stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
+ visit(explore_projects_path)
+ end
+
+ it 'redirects to login page' do
+ expect(page).to have_current_path(new_user_session_path)
+ end
+ end
end
- context 'when visibility is restricted to public' do
+ context 'when signed in' do
+ let_it_be(:user) { create(:user) }
+
before do
- stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC])
- visit(explore_projects_path)
+ sign_in(user)
+
+ stub_feature_flags(project_list_filter_bar: false)
+ end
+
+ shared_examples 'empty search results' do
+ it 'shows correct empty state message', :js do
+ fill_in 'name', with: 'zzzzzzzzzzzzzzzzzzz'
+
+ expect(page).to have_content('Explore public groups to find projects to contribute to.')
+ end
end
- it 'redirects to login page' do
- expect(page).to have_current_path(new_user_session_path)
+ context 'when viewing public projects' do
+ before do
+ visit(explore_projects_path)
+ end
+
+ include_examples 'shows public and internal projects'
+ include_examples 'empty search results'
+ end
+
+ context 'when viewing most starred projects' do
+ before do
+ visit(starred_explore_projects_path)
+ end
+
+ include_examples 'shows public and internal projects'
+ include_examples 'empty search results'
+ end
+
+ context 'when viewing trending projects' do
+ before do
+ [archived_project, public_project].each { |project| create(:note_on_issue, project: project) }
+
+ TrendingProject.refresh!
+
+ visit(trending_explore_projects_path)
+ end
+
+ include_examples 'shows public projects'
+ include_examples 'empty search results'
end
end
end
- context 'when signed in' do
- let_it_be(:user) { create(:user) }
-
- before do
- sign_in(user)
+ context 'when there are no projects' do
+ shared_examples 'explore page empty state' do
+ it 'shows correct empty state message' do
+ expect(page).to have_content('Explore public groups to find projects to contribute to.')
+ end
end
context 'when viewing public projects' do
before do
- visit(explore_projects_path)
+ visit explore_projects_path
end
- include_examples 'shows public and internal projects'
+ it_behaves_like 'explore page empty state'
end
context 'when viewing most starred projects' do
before do
- visit(starred_explore_projects_path)
+ visit starred_explore_projects_path
end
- include_examples 'shows public and internal projects'
+ it_behaves_like 'explore page empty state'
end
context 'when viewing trending projects' do
before do
- [archived_project, public_project].each { |project| create(:note_on_issue, project: project) }
-
- TrendingProject.refresh!
-
- visit(trending_explore_projects_path)
+ visit trending_explore_projects_path
end
- include_examples 'shows public projects'
+ it_behaves_like 'explore page empty state'
end
end
end
diff --git a/spec/features/file_uploads/multipart_invalid_uploads_spec.rb b/spec/features/file_uploads/multipart_invalid_uploads_spec.rb
index e9e24c12af1..b3ace2e30ff 100644
--- a/spec/features/file_uploads/multipart_invalid_uploads_spec.rb
+++ b/spec/features/file_uploads/multipart_invalid_uploads_spec.rb
@@ -22,13 +22,13 @@ RSpec.describe 'Invalid uploads that must be rejected', :api, :js do
)
end
- RSpec.shared_examples 'rejecting invalid keys' do |key_name:, message: nil|
+ RSpec.shared_examples 'rejecting invalid keys' do |key_name:, message: nil, status: 500|
context "with invalid key #{key_name}" do
let(:body) { { key_name => file, 'package[test][name]' => 'test' } }
it { expect { subject }.not_to change { Packages::Package.nuget.count } }
- it { expect(subject.code).to eq(500) }
+ it { expect(subject.code).to eq(status) }
it { expect(subject.body).to include(message.presence || "invalid field: \"#{key_name}\"") }
end
@@ -45,7 +45,7 @@ RSpec.describe 'Invalid uploads that must be rejected', :api, :js do
# These keys are rejected directly by rack itself.
# The request will not be received by multipart.rb (can't use the 'handling file uploads' shared example)
it_behaves_like 'rejecting invalid keys', key_name: 'x' * 11000, message: 'Puma caught this error: exceeded available parameter key space (RangeError)'
- it_behaves_like 'rejecting invalid keys', key_name: 'package[]test', message: 'Puma caught this error: expected Hash (got Array)'
+ it_behaves_like 'rejecting invalid keys', key_name: 'package[]test', status: 400, message: 'Bad Request'
it_behaves_like 'handling file uploads', 'by rejecting uploads with an invalid key'
end
diff --git a/spec/features/frequently_visited_projects_and_groups_spec.rb b/spec/features/frequently_visited_projects_and_groups_spec.rb
new file mode 100644
index 00000000000..b8797d9c139
--- /dev/null
+++ b/spec/features/frequently_visited_projects_and_groups_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Frequently visited items', :js do
+ let_it_be(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ end
+
+ context 'for projects' do
+ let_it_be(:project) { create(:project, :public) }
+
+ it 'increments localStorage counter when visiting the project' do
+ visit project_path(project)
+
+ frequent_projects = nil
+
+ wait_for('localStorage frequent-projects') do
+ frequent_projects = page.evaluate_script("localStorage['#{user.username}/frequent-projects']")
+
+ frequent_projects.present?
+ end
+
+ expect(Gitlab::Json.parse(frequent_projects)).to contain_exactly(a_hash_including('id' => project.id, 'frequency' => 1))
+ end
+ end
+
+ context 'for groups' do
+ let_it_be(:group) { create(:group, :public) }
+
+ it 'increments localStorage counter when visiting the group' do
+ visit group_path(group)
+
+ frequent_groups = nil
+
+ wait_for('localStorage frequent-groups') do
+ frequent_groups = page.evaluate_script("localStorage['#{user.username}/frequent-groups']")
+
+ frequent_groups.present?
+ end
+
+ expect(Gitlab::Json.parse(frequent_groups)).to contain_exactly(a_hash_including('id' => group.id, 'frequency' => 1))
+ end
+ end
+end
diff --git a/spec/features/global_search_spec.rb b/spec/features/global_search_spec.rb
index 0ca626381d4..e6e4a55c1bb 100644
--- a/spec/features/global_search_spec.rb
+++ b/spec/features/global_search_spec.rb
@@ -36,15 +36,15 @@ RSpec.describe 'Global search' do
end
end
- it 'closes the dropdown on blur', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/201841' do
+ it 'closes the dropdown on blur', :js do
+ find('#search').click
fill_in 'search', with: "a"
- dropdown = find('.js-dashboard-search-options')
- expect(dropdown[:class]).to include 'show'
+ expect(page).to have_selector("div[data-testid='dashboard-search-options'].show")
find('#search').send_keys(:backspace)
find('body').click
- expect(dropdown[:class]).not_to include 'show'
+ expect(page).to have_no_selector("div[data-testid='dashboard-search-options'].show")
end
end
diff --git a/spec/features/group_variables_spec.rb b/spec/features/group_variables_spec.rb
index 9a3dca61680..c7d37205b71 100644
--- a/spec/features/group_variables_spec.rb
+++ b/spec/features/group_variables_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe 'Group variables', :js do
before do
group.add_owner(user)
gitlab_sign_in(user)
- stub_feature_flags(new_variables_ui: false)
+ wait_for_requests
visit page_path
end
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index acac8724edf..1b23b8b4bf9 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -89,6 +89,20 @@ RSpec.describe 'Container Registry', :js do
end
end
+ context 'when an image has the same name as the subgroup' do
+ before do
+ stub_container_registry_tags(tags: %w[latest], with_manifest: true)
+ project.container_repositories << create(:container_repository, name: group.name)
+ visit_container_registry
+ end
+
+ it 'details page loads properly' do
+ find('a[data-testid="details-link"]').click
+
+ expect(page).to have_content 'latest'
+ end
+ end
+
def visit_container_registry
visit group_container_registries_path(group)
end
diff --git a/spec/features/groups/dependency_proxy_spec.rb b/spec/features/groups/dependency_proxy_spec.rb
new file mode 100644
index 00000000000..9bbfdc488fb
--- /dev/null
+++ b/spec/features/groups/dependency_proxy_spec.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Group Dependency Proxy' do
+ let(:developer) { create(:user) }
+ let(:reporter) { create(:user) }
+ let(:group) { create(:group) }
+ let(:path) { group_dependency_proxy_path(group) }
+
+ before do
+ group.add_developer(developer)
+ group.add_reporter(reporter)
+
+ enable_feature
+ end
+
+ describe 'feature settings' do
+ context 'when not logged in and feature disabled' do
+ it 'does not show the feature settings' do
+ group.create_dependency_proxy_setting(enabled: false)
+
+ visit path
+
+ expect(page).not_to have_css('.js-dependency-proxy-toggle-area')
+ expect(page).not_to have_css('.js-dependency-proxy-url')
+ end
+ end
+
+ context 'feature is available', :js do
+ context 'when logged in as group developer' do
+ before do
+ sign_in(developer)
+ visit path
+ end
+
+ it 'sidebar menu is open' do
+ sidebar = find('.nav-sidebar')
+ expect(sidebar).to have_link _('Dependency Proxy')
+ end
+
+ it 'toggles defaults to enabled' do
+ page.within('.js-dependency-proxy-toggle-area') do
+ expect(find('.js-project-feature-toggle-input', visible: false).value).to eq('true')
+ end
+ end
+
+ it 'shows the proxy URL' do
+ page.within('.edit_dependency_proxy_group_setting') do
+ expect(find('.js-dependency-proxy-url').value).to have_content('/dependency_proxy/containers')
+ end
+ end
+
+ it 'hides the proxy URL when feature is disabled' do
+ page.within('.edit_dependency_proxy_group_setting') do
+ find('.js-project-feature-toggle').click
+ end
+
+ expect(page).not_to have_css('.js-dependency-proxy-url')
+ expect(find('.js-project-feature-toggle-input', visible: false).value).to eq('false')
+ end
+ end
+
+ context 'when logged in as group reporter' do
+ before do
+ sign_in(reporter)
+ visit path
+ end
+
+ it 'does not show the feature toggle but shows the proxy URL' do
+ expect(page).not_to have_css('.js-dependency-proxy-toggle-area')
+ expect(find('.js-dependency-proxy-url').value).to have_content('/dependency_proxy/containers')
+ end
+ end
+ end
+
+ context 'feature is not avaible' do
+ before do
+ sign_in(developer)
+ end
+
+ context 'group is private' do
+ let(:group) { create(:group, :private) }
+
+ it 'informs user that feature is only available for public groups' do
+ visit path
+
+ expect(page).to have_content('Dependency proxy feature is limited to public groups for now.')
+ end
+ end
+
+ context 'feature is disabled globally' do
+ it 'renders 404 page' do
+ disable_feature
+
+ visit path
+
+ expect(page).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+ end
+
+ def enable_feature
+ stub_config(dependency_proxy: { enabled: true })
+ end
+
+ def disable_feature
+ stub_config(dependency_proxy: { enabled: false })
+ end
+end
diff --git a/spec/features/groups/members/filter_members_spec.rb b/spec/features/groups/members/filter_members_spec.rb
index d667690af29..b6d33b3f4aa 100644
--- a/spec/features/groups/members/filter_members_spec.rb
+++ b/spec/features/groups/members/filter_members_spec.rb
@@ -2,16 +2,19 @@
require 'spec_helper'
-RSpec.describe 'Groups > Members > Filter members' do
+RSpec.describe 'Groups > Members > Filter members', :js do
+ include Spec::Support::Helpers::Features::MembersHelpers
+
let(:user) { create(:user) }
let(:nested_group_user) { create(:user) }
let(:user_with_2fa) { create(:user, :two_factor_via_otp) }
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
- before do
- stub_feature_flags(vue_group_members_list: false)
+ two_factor_auth_dropdown_toggle_selector = '[data-testid="member-filter-2fa-dropdown"] [data-testid="dropdown-toggle"]'
+ active_inherited_members_filter_selector = '[data-testid="filter-members-with-inherited-permissions"] a.is-active'
+ before do
group.add_owner(user)
group.add_maintainer(user_with_2fa)
nested_group.add_maintainer(nested_group_user)
@@ -24,23 +27,23 @@ RSpec.describe 'Groups > Members > Filter members' do
expect(member(0)).to include(user.name)
expect(member(1)).to include(user_with_2fa.name)
- expect(page).to have_css('.member-filter-2fa-dropdown .dropdown-toggle-text', text: 'Everyone')
+ expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Everyone')
end
it 'shows only 2FA members' do
visit_members_list(group, two_factor: 'enabled')
expect(member(0)).to include(user_with_2fa.name)
- expect(members_list.size).to eq(1)
- expect(page).to have_css('.member-filter-2fa-dropdown .dropdown-toggle-text', text: 'Enabled')
+ expect(all_rows.size).to eq(1)
+ expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Enabled')
end
it 'shows only non 2FA members' do
visit_members_list(group, two_factor: 'disabled')
expect(member(0)).to include(user.name)
- expect(members_list.size).to eq(1)
- expect(page).to have_css('.member-filter-2fa-dropdown .dropdown-toggle-text', text: 'Disabled')
+ expect(all_rows.size).to eq(1)
+ expect(page).to have_css(two_factor_auth_dropdown_toggle_selector, text: 'Disabled')
end
it 'shows inherited members by default' do
@@ -49,35 +52,31 @@ RSpec.describe 'Groups > Members > Filter members' do
expect(member(0)).to include(user.name)
expect(member(1)).to include(user_with_2fa.name)
expect(member(2)).to include(nested_group_user.name)
- expect(members_list.size).to eq(3)
+ expect(all_rows.size).to eq(3)
- expect(page).to have_css('[data-qa-selector="filter-members-with-inherited-permissions"] a.is-active', text: 'Show all members')
+ expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show all members', visible: false)
end
it 'shows only group members' do
visit_members_list(nested_group, with_inherited_permissions: 'exclude')
expect(member(0)).to include(nested_group_user.name)
- expect(members_list.size).to eq(1)
- expect(page).to have_css('[data-qa-selector="filter-members-with-inherited-permissions"] a.is-active', text: 'Show only direct members')
+ expect(all_rows.size).to eq(1)
+ expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show only direct members', visible: false)
end
it 'shows only inherited members' do
visit_members_list(nested_group, with_inherited_permissions: 'only')
expect(member(0)).to include(user.name)
expect(member(1)).to include(user_with_2fa.name)
- expect(members_list.size).to eq(2)
- expect(page).to have_css('[data-qa-selector="filter-members-with-inherited-permissions"] a.is-active', text: 'Show only inherited members')
+ expect(all_rows.size).to eq(2)
+ expect(page).to have_css(active_inherited_members_filter_selector, text: 'Show only inherited members', visible: false)
end
def visit_members_list(group, options = {})
visit group_group_members_path(group.to_param, options)
end
- def members_list
- page.all('ul.content-list > li')
- end
-
def member(number)
- members_list[number].text
+ all_rows[number].text
end
end
diff --git a/spec/features/groups/members/leave_group_spec.rb b/spec/features/groups/members/leave_group_spec.rb
index 32acf7edd2a..b73313745e9 100644
--- a/spec/features/groups/members/leave_group_spec.rb
+++ b/spec/features/groups/members/leave_group_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Leave group' do
+ include Spec::Support::Helpers::Features::MembersHelpers
+
let(:user) { create(:user) }
let(:other_user) { create(:user) }
let(:group) { create(:group) }
before do
- stub_feature_flags(vue_group_members_list: false)
-
- gitlab_sign_in(user)
+ sign_in(user)
end
it 'guest leaves the group' do
@@ -61,7 +61,7 @@ RSpec.describe 'Groups > Members > Leave group' do
expect(group.users).not_to include(user)
end
- it 'owner can not leave the group if they are the last owner' do
+ it 'owner can not leave the group if they are the last owner', :js do
group.add_owner(user)
visit group_path(group)
@@ -70,7 +70,7 @@ RSpec.describe 'Groups > Members > Leave group' do
visit group_group_members_path(group)
- expect(find(:css, '.project-members-page li', text: user.name)).to have_no_selector(:css, 'a.btn-danger')
+ expect(members_table).not_to have_selector 'button[title="Leave"]'
end
it 'owner can not leave the group by url param if they are the last owner', :js do
diff --git a/spec/features/groups/members/list_members_spec.rb b/spec/features/groups/members/list_members_spec.rb
index bcec2b50a24..b0a896ec8cb 100644
--- a/spec/features/groups/members/list_members_spec.rb
+++ b/spec/features/groups/members/list_members_spec.rb
@@ -2,9 +2,8 @@
require 'spec_helper'
-RSpec.describe 'Groups > Members > List members' do
- include Select2Helper
- include Spec::Support::Helpers::Features::ListRowsHelpers
+RSpec.describe 'Groups > Members > List members', :js do
+ include Spec::Support::Helpers::Features::MembersHelpers
let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') }
@@ -12,8 +11,6 @@ RSpec.describe 'Groups > Members > List members' do
let(:nested_group) { create(:group, parent: group) }
before do
- stub_feature_flags(vue_group_members_list: false)
-
sign_in(user1)
end
@@ -42,10 +39,12 @@ RSpec.describe 'Groups > Members > List members' do
group.add_developer(user2)
end
- subject { visit group_group_members_path(group) }
+ it 'shows the status' do
+ create(:user_status, user: user2, emoji: 'smirk', message: 'Authoring this object')
+
+ visit group_group_members_path(nested_group)
- it_behaves_like 'showing user status' do
- let(:user_with_status) { user2 }
+ expect(first_row).to have_selector('gl-emoji[data-name="smirk"]')
end
end
end
diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb
index 33caa3af36d..31a2c868cac 100644
--- a/spec/features/groups/members/manage_groups_spec.rb
+++ b/spec/features/groups/members/manage_groups_spec.rb
@@ -4,13 +4,11 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Manage groups', :js do
include Select2Helper
- include Spec::Support::Helpers::Features::ListRowsHelpers
+ include Spec::Support::Helpers::Features::MembersHelpers
let_it_be(:user) { create(:user) }
before do
- stub_feature_flags(vue_group_members_list: false)
-
sign_in(user)
end
@@ -51,7 +49,6 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
end
before do
- travel_to Time.now.utc.beginning_of_day
group_link.update!(additional_link_attrs)
shared_group.add_owner(user)
@@ -63,8 +60,12 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
expect(page).to have_content(shared_with_group.name)
- accept_confirm do
- find(:css, '#tab-groups li', text: shared_with_group.name).find(:css, 'a.btn-danger').click
+ page.within(first_row) do
+ click_button 'Remove group'
+ end
+
+ page.within('[role="dialog"]') do
+ click_button('Remove group')
end
expect(page).not_to have_content(shared_with_group.name)
@@ -75,7 +76,7 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
page.within(first_row) do
click_button('Developer')
- click_link('Maintainer')
+ click_button('Maintainer')
wait_for_requests
@@ -86,33 +87,30 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
it 'updates expiry date' do
click_groups_tab
- expires_at_field = "member_expires_at_#{shared_with_group.id}"
- fill_in "member_expires_at_#{shared_with_group.id}", with: 3.days.from_now.to_date
+ page.within first_row do
+ fill_in 'Expiration date', with: 5.days.from_now.to_date
+ find_field('Expiration date').native.send_keys :enter
- find_field(expires_at_field).native.send_keys :enter
- wait_for_requests
+ wait_for_requests
- page.within(find('li.group_member')) do
- expect(page).to have_content('Expires in 3 days')
+ expect(page).to have_content(/in \d days/)
end
end
context 'when expiry date is set' do
- let(:additional_link_attrs) { { expires_at: 3.days.from_now.to_date } }
+ let(:additional_link_attrs) { { expires_at: 5.days.from_now.to_date } }
it 'clears expiry date' do
click_groups_tab
- page.within(find('li.group_member')) do
- expect(page).to have_content('Expires in 3 days')
+ page.within first_row do
+ expect(page).to have_content(/in \d days/)
- page.within(find('.js-edit-member-form')) do
- find('.js-clear-input').click
- end
+ find('[data-testid="clear-button"]').click
wait_for_requests
- expect(page).not_to have_content('Expires in')
+ expect(page).to have_content('No expiration set')
end
end
end
@@ -128,6 +126,7 @@ RSpec.describe 'Groups > Members > Manage groups', :js do
end
def click_groups_tab
+ expect(page).to have_link 'Groups'
click_link "Groups"
end
end
diff --git a/spec/features/groups/members/manage_members_spec.rb b/spec/features/groups/members/manage_members_spec.rb
index aedb7c170f8..e6da05c4873 100644
--- a/spec/features/groups/members/manage_members_spec.rb
+++ b/spec/features/groups/members/manage_members_spec.rb
@@ -4,15 +4,13 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Manage members' do
include Select2Helper
- include Spec::Support::Helpers::Features::ListRowsHelpers
+ include Spec::Support::Helpers::Features::MembersHelpers
let(:user1) { create(:user, name: 'John Doe') }
let(:user2) { create(:user, name: 'Mary Jane') }
let(:group) { create(:group) }
before do
- stub_feature_flags(vue_group_members_list: false)
-
sign_in(user1)
end
@@ -24,7 +22,7 @@ RSpec.describe 'Groups > Members > Manage members' do
page.within(second_row) do
click_button('Developer')
- click_link('Owner')
+ click_button('Owner')
expect(page).to have_button('Owner')
end
@@ -71,11 +69,14 @@ RSpec.describe 'Groups > Members > Manage members' do
visit group_group_members_path(group)
# Open modal
- find(:css, '.project-members-page li', text: user2.name).find(:css, 'button.btn-danger').click
-
- expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
+ page.within(second_row) do
+ click_button 'Remove member'
+ end
- click_on('Remove member')
+ page.within('[role="dialog"]') do
+ expect(page).to have_unchecked_field 'Also unassign this user from related issues and merge requests'
+ click_button('Remove member')
+ end
wait_for_requests
@@ -103,16 +104,17 @@ RSpec.describe 'Groups > Members > Manage members' do
add_user('test@example.com', 'Reporter')
- click_link('Invited')
+ expect(page).to have_link 'Invited'
+ click_link 'Invited'
- page.within('.content-list.members-list') do
+ page.within(members_table) do
expect(page).to have_content('test@example.com')
expect(page).to have_content('Invited')
expect(page).to have_button('Reporter')
end
end
- it 'guest can not manage other users' do
+ it 'guest can not manage other users', :js do
group.add_guest(user1)
group.add_developer(user2)
@@ -126,7 +128,7 @@ RSpec.describe 'Groups > Members > Manage members' do
expect(page).not_to have_button 'Developer'
# Can not remove user2
- expect(page).not_to have_css('a.btn-danger')
+ expect(page).not_to have_selector 'button[title="Remove member"]'
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
index dd708c243a8..de9b32e00aa 100644
--- a/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
+++ b/spec/features/groups/members/master_adds_member_with_expiration_date_spec.rb
@@ -4,17 +4,13 @@ require 'spec_helper'
RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js do
include Select2Helper
- include ActiveSupport::Testing::TimeHelpers
+ include Spec::Support::Helpers::Features::MembersHelpers
let_it_be(:user1) { create(:user, name: 'John Doe') }
let_it_be(:group) { create(:group) }
let(:new_member) { create(:user, name: 'Mary Jane') }
before do
- stub_feature_flags(vue_group_members_list: false)
-
- travel_to Time.now.utc.beginning_of_day
-
group.add_owner(user1)
sign_in(user1)
end
@@ -22,17 +18,17 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js
it 'expiration date is displayed in the members list' do
visit group_group_members_path(group)
- page.within '.invite-users-form' do
+ page.within invite_users_form do
select2(new_member.id, from: '#user_ids', multiple: true)
- fill_in 'expires_at', with: 3.days.from_now.to_date
+ fill_in 'expires_at', with: 5.days.from_now.to_date
find_field('expires_at').native.send_keys :enter
click_on 'Invite'
end
- page.within "#group_member_#{group_member_id}" do
- expect(page).to have_content('Expires in 3 days')
+ page.within second_row do
+ expect(page).to have_content(/in \d days/)
end
end
@@ -40,32 +36,28 @@ RSpec.describe 'Groups > Members > Owner adds member with expiration date', :js
group.add_developer(new_member)
visit group_group_members_path(group)
- page.within "#group_member_#{group_member_id}" do
- fill_in 'Expiration date', with: 3.days.from_now.to_date
+ page.within second_row do
+ fill_in 'Expiration date', with: 5.days.from_now.to_date
find_field('Expiration date').native.send_keys :enter
wait_for_requests
- expect(page).to have_content('Expires in 3 days')
+ expect(page).to have_content(/in \d days/)
end
end
it 'clears expiration date' do
- create(:group_member, :developer, user: new_member, group: group, expires_at: 3.days.from_now.to_date)
+ create(:group_member, :developer, user: new_member, group: group, expires_at: 5.days.from_now.to_date)
visit group_group_members_path(group)
- page.within "#group_member_#{group_member_id}" do
- expect(page).to have_content('Expires in 3 days')
+ page.within second_row do
+ expect(page).to have_content(/in \d days/)
- find('.js-clear-input').click
+ find('[data-testid="clear-button"]').click
wait_for_requests
- expect(page).not_to have_content('Expires in')
+ expect(page).to have_content('No expiration set')
end
end
-
- def group_member_id
- group.members.find_by(user_id: new_member).id
- end
end
diff --git a/spec/features/groups/members/master_manages_access_requests_spec.rb b/spec/features/groups/members/master_manages_access_requests_spec.rb
index 44fd7380b79..71c9b280ebe 100644
--- a/spec/features/groups/members/master_manages_access_requests_spec.rb
+++ b/spec/features/groups/members/master_manages_access_requests_spec.rb
@@ -3,10 +3,6 @@
require 'spec_helper'
RSpec.describe 'Groups > Members > Maintainer manages access requests' do
- before do
- stub_feature_flags(vue_group_members_list: false)
- end
-
it_behaves_like 'Maintainer manages access requests' do
let(:has_tabs) { true }
let(:entity) { create(:group, :public) }
diff --git a/spec/features/groups/members/search_members_spec.rb b/spec/features/groups/members/search_members_spec.rb
index a95b59cece1..0b2d2fd478d 100644
--- a/spec/features/groups/members/search_members_spec.rb
+++ b/spec/features/groups/members/search_members_spec.rb
@@ -2,7 +2,9 @@
require 'spec_helper'
-RSpec.describe 'Search group member' do
+RSpec.describe 'Search group member', :js do
+ include Spec::Support::Helpers::Features::MembersHelpers
+
let(:user) { create :user }
let(:member) { create :user }
@@ -14,8 +16,6 @@ RSpec.describe 'Search group member' do
end
before do
- stub_feature_flags(vue_group_members_list: false)
-
sign_in(user)
visit group_group_members_path(guest_group)
end
@@ -23,11 +23,10 @@ RSpec.describe 'Search group member' do
it 'renders member users' do
page.within '[data-testid="user-search-form"]' do
fill_in 'search', with: member.name
- find('.user-search-btn').click
+ find('[data-testid="user-search-submit"]').click
end
- group_members_list = find('[data-qa-selector="members_list"]')
- expect(group_members_list).to have_content(member.name)
- expect(group_members_list).not_to have_content(user.name)
+ expect(members_table).to have_content(member.name)
+ expect(members_table).not_to have_content(user.name)
end
end
diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb
index d940550b18a..f03cc36df18 100644
--- a/spec/features/groups/members/sort_members_spec.rb
+++ b/spec/features/groups/members/sort_members_spec.rb
@@ -2,14 +2,16 @@
require 'spec_helper'
-RSpec.describe 'Groups > Members > Sort members' do
+RSpec.describe 'Groups > Members > Sort members', :js do
+ include Spec::Support::Helpers::Features::MembersHelpers
+
let(:owner) { create(:user, name: 'John Doe') }
let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) }
let(:group) { create(:group) }
- before do
- stub_feature_flags(vue_group_members_list: false)
+ dropdown_toggle_selector = '[data-testid="user-sort-dropdown"] [data-testid="dropdown-toggle"]'
+ before do
create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago)
create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago)
@@ -19,84 +21,76 @@ RSpec.describe 'Groups > Members > Sort members' do
it 'sorts alphabetically by default' do
visit_members_list(sort: nil)
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Name, ascending')
end
it 'sorts by access level ascending' do
visit_members_list(sort: :access_level_asc)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending')
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Access level, ascending')
end
it 'sorts by access level descending' do
visit_members_list(sort: :access_level_desc)
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending')
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Access level, descending')
end
it 'sorts by last joined' do
visit_members_list(sort: :last_joined)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Last joined')
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Last joined')
end
it 'sorts by oldest joined' do
visit_members_list(sort: :oldest_joined)
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined')
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Oldest joined')
end
it 'sorts by name ascending' do
visit_members_list(sort: :name_asc)
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending')
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Name, ascending')
end
it 'sorts by name descending' do
visit_members_list(sort: :name_desc)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Name, descending')
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Name, descending')
end
it 'sorts by recent sign in', :clean_gitlab_redis_shared_state do
visit_members_list(sort: :recent_sign_in)
- expect(first_member).to include(owner.name)
- expect(second_member).to include(developer.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in')
+ expect(first_row.text).to include(owner.name)
+ expect(second_row.text).to include(developer.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Recent sign in')
end
it 'sorts by oldest sign in', :clean_gitlab_redis_shared_state do
visit_members_list(sort: :oldest_sign_in)
- expect(first_member).to include(developer.name)
- expect(second_member).to include(owner.name)
- expect(page).to have_css('.qa-user-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in')
+ expect(first_row.text).to include(developer.name)
+ expect(second_row.text).to include(owner.name)
+ expect(page).to have_css(dropdown_toggle_selector, text: 'Oldest sign in')
end
def visit_members_list(sort:)
visit group_group_members_path(group.to_param, sort: sort)
end
-
- def first_member
- page.all('ul.content-list > li').first.text
- end
-
- def second_member
- page.all('ul.content-list > li').last.text
- end
end
diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb
index 3ae9a2b7555..8d1008b98a6 100644
--- a/spec/features/groups/milestone_spec.rb
+++ b/spec/features/groups/milestone_spec.rb
@@ -83,6 +83,7 @@ RSpec.describe 'Group milestones' do
description: 'Lorem Ipsum is simply dummy text'
)
end
+
let_it_be(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.1') }
let_it_be(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') }
let_it_be(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') }
diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb
index e81f2370d10..dec07eb3783 100644
--- a/spec/features/groups/navbar_spec.rb
+++ b/spec/features/groups/navbar_spec.rb
@@ -50,6 +50,8 @@ RSpec.describe 'Group navbar' do
insert_package_nav(_('Kubernetes'))
stub_feature_flags(group_iterations: false)
+ stub_config(dependency_proxy: { enabled: false })
+ stub_config(registry: { enabled: false })
stub_group_wikis(false)
group.add_maintainer(user)
sign_in(user)
@@ -73,6 +75,18 @@ RSpec.describe 'Group navbar' do
it_behaves_like 'verified navigation bar'
end
+ context 'when dependency proxy is available' do
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+
+ insert_dependency_proxy_nav(_('Dependency Proxy'))
+
+ visit group_path(group)
+ end
+
+ it_behaves_like 'verified navigation bar'
+ end
+
context 'when invite team members is not available' do
it 'does not display the js-invite-members-trigger' do
visit group_path(group)
diff --git a/spec/features/groups/settings/repository_spec.rb b/spec/features/groups/settings/repository_spec.rb
index d20303027e5..3c1609a2605 100644
--- a/spec/features/groups/settings/repository_spec.rb
+++ b/spec/features/groups/settings/repository_spec.rb
@@ -25,4 +25,21 @@ RSpec.describe 'Group Repository settings' do
let(:entity_type) { 'group' }
end
end
+
+ context 'Default initial branch name' do
+ before do
+ visit group_settings_repository_path(group)
+ end
+
+ it 'has the setting section' do
+ expect(page).to have_css("#js-default-branch-name")
+ end
+
+ it 'renders the correct setting section content' do
+ within("#js-default-branch-name") do
+ expect(page).to have_content("Default initial branch name")
+ expect(page).to have_content("Set the default name of the initial branch when creating new repositories through the user interface.")
+ end
+ end
+ end
end
diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb
index 304573ecd6e..97732374eb9 100644
--- a/spec/features/groups/show_spec.rb
+++ b/spec/features/groups/show_spec.rb
@@ -81,8 +81,7 @@ RSpec.describe 'Group show page' do
it 'allows creating subgroups' do
visit path
- expect(page)
- .to have_css("li[data-text='New subgroup']", visible: false)
+ expect(page).to have_link('New subgroup')
end
end
end
@@ -102,8 +101,7 @@ RSpec.describe 'Group show page' do
path = group_path(relaxed_group)
visit path
- expect(page)
- .to have_css("li[data-text='New subgroup']", visible: false)
+ expect(page).to have_link('New subgroup')
end
end
@@ -116,9 +114,7 @@ RSpec.describe 'Group show page' do
path = group_path(restricted_group)
visit path
- expect(page)
- .not_to have_selector("li[data-text='New subgroup']",
- visible: false)
+ expect(page).not_to have_link('New subgroup')
end
end
end
diff --git a/spec/features/groups_spec.rb b/spec/features/groups_spec.rb
index 8264ec2eddd..b9fd3a1a5cc 100644
--- a/spec/features/groups_spec.rb
+++ b/spec/features/groups_spec.rb
@@ -294,35 +294,43 @@ RSpec.describe 'Group' do
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)
+ context 'when user has subgroup creation permissions but not project creation permissions' do
+ it 'only displays "New subgroup" button' do
+ visit group_path(group)
- page.within '.group-buttons' do
- expect(page).to have_link('New subgroup')
+ page.within '[data-testid="group-buttons"]' do
+ expect(page).to have_link('New subgroup')
+ expect(page).not_to have_link('New project')
+ end
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)
+ context 'when user has project creation permissions but not subgroup creation permissions' do
+ it 'only displays "New project" button' do
+ group.update!(project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
+ user = create(:user)
- 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)
+ group.add_maintainer(user)
+ sign_out(:user)
+ sign_in(user)
+
+ visit group_path(group)
+ page.within '[data-testid="group-buttons"]' do
+ expect(page).to have_link('New project')
+ expect(page).not_to have_link('New subgroup')
+ end
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)
+ context 'when user has project and subgroup creation permissions' do
+ it 'displays "New subgroup" and "New project" buttons' do
+ group.update!(project_creation_level: Gitlab::Access::MAINTAINER_PROJECT_ACCESS)
+ visit group_path(group)
- visit group_path(group)
- page.within '.group-buttons' do
- expect(page).to have_link('New project')
+ page.within '[data-testid="group-buttons"]' do
+ expect(page).to have_link('New subgroup')
+ expect(page).to have_link('New project')
+ end
end
end
end
diff --git a/spec/features/ide/user_sees_editor_info_spec.rb b/spec/features/ide/user_sees_editor_info_spec.rb
new file mode 100644
index 00000000000..3760d6bd435
--- /dev/null
+++ b/spec/features/ide/user_sees_editor_info_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'IDE user sees editor info', :js do
+ include WebIdeSpecHelpers
+
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:user) { project.owner }
+
+ before do
+ sign_in(user)
+
+ ide_visit(project)
+ end
+
+ it 'shows line position' do
+ ide_open_file('README.md')
+
+ within find('.ide-status-bar') do
+ expect(page).to have_content('1:1')
+ end
+
+ ide_set_editor_position(4, 10)
+
+ within find('.ide-status-bar') do
+ expect(page).not_to have_content('1:1')
+ expect(page).to have_content('4:10')
+ end
+ end
+
+ it 'updates after rename' do
+ ide_open_file('README.md')
+ ide_set_editor_position(4, 10)
+
+ within find('.ide-status-bar') do
+ expect(page).to have_content('markdown')
+ expect(page).to have_content('4:10')
+ end
+
+ ide_rename_file('README.md', 'READMEZ.txt')
+
+ within find('.ide-status-bar') do
+ expect(page).to have_content('plaintext')
+ expect(page).to have_content('1:1')
+ end
+ end
+
+ it 'persists position after rename' do
+ ide_open_file('README.md')
+ ide_set_editor_position(4, 10)
+
+ ide_open_file('files/js/application.js')
+ ide_rename_file('README.md', 'READING_RAINBOW.md')
+
+ ide_open_file('READING_RAINBOW.md')
+
+ within find('.ide-status-bar') do
+ expect(page).to have_content('4:10')
+ end
+ end
+
+ it 'persists position' do
+ ide_open_file('README.md')
+ ide_set_editor_position(4, 10)
+
+ ide_close_file('README.md')
+ ide_open_file('README.md')
+
+ within find('.ide-status-bar') do
+ expect(page).to have_content('markdown')
+ expect(page).to have_content('4:10')
+ end
+ end
+
+ it 'persists viewer' do
+ ide_open_file('README.md')
+ click_link('Preview Markdown')
+
+ within find('.md-previewer') do
+ expect(page).to have_content('testme')
+ end
+
+ # Switch away from and back to the file
+ ide_open_file('.gitignore')
+ ide_open_file('README.md')
+
+ # Preview is still enabled
+ within find('.md-previewer') do
+ expect(page).to have_content('testme')
+ end
+ end
+end
diff --git a/spec/features/incidents/user_views_incident_spec.rb b/spec/features/incidents/user_views_incident_spec.rb
new file mode 100644
index 00000000000..3595f5c03ec
--- /dev/null
+++ b/spec/features/incidents/user_views_incident_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "User views incident" do
+ let_it_be(:project) { create(:project_empty_repo, :public) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:incident) { create(:incident, project: project, description: "# Description header\n\n**Lorem** _ipsum_ dolor sit [amet](https://example.com)", author: user) }
+ let_it_be(:note) { create(:note, noteable: incident, project: project, author: user) }
+
+ before_all do
+ project.add_developer(user)
+ end
+
+ before do
+ stub_feature_flags(vue_issue_header: false)
+
+ sign_in(user)
+
+ visit(project_issues_incident_path(project, incident))
+ end
+
+ it { expect(page).to have_header_with_correct_id_and_link(1, "Description header", "description-header") }
+
+ it_behaves_like 'page meta description', ' Description header Lorem ipsum dolor sit amet'
+
+ it 'shows the merge request and incident actions', :aggregate_failures do
+ expect(page).to have_link('New incident')
+ expect(page).to have_button('Create merge request')
+ expect(page).to have_link('Close incident')
+ end
+
+ context 'when the project is archived' do
+ before do
+ project.update!(archived: true)
+ visit(project_issues_incident_path(project, incident))
+ end
+
+ it 'hides the merge request and incident actions', :aggregate_failures do
+ expect(page).not_to have_link('New incident')
+ expect(page).not_to have_button('Create merge request')
+ expect(page).not_to have_link('Close incident')
+ end
+ end
+
+ describe 'user status' do
+ subject { visit(project_issues_incident_path(project, incident)) }
+
+ context 'when showing status of the author of the incident' do
+ it_behaves_like 'showing user status' do
+ let(:user_with_status) { user }
+ end
+ end
+
+ context 'when showing status of a user who commented on an incident', :js do
+ it_behaves_like 'showing user status' do
+ let(:user_with_status) { user }
+ end
+ end
+
+ context 'when status message has an emoji', :js do
+ let_it_be(:message) { 'My status with an emoji' }
+ let_it_be(:message_emoji) { 'basketball' }
+ let_it_be(:status) { create(:user_status, user: user, emoji: 'smirk', message: "#{message} :#{message_emoji}:") }
+
+ it 'correctly renders the emoji' do
+ wait_for_requests
+
+ tooltip_span = page.first(".user-status-emoji[title^='#{message}']")
+ tooltip_span.hover
+
+ wait_for_requests
+
+ tooltip = page.find('.tooltip .tooltip-inner')
+
+ page.within(tooltip) do
+ expect(page).to have_emoji(message_emoji)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/features/invites_spec.rb b/spec/features/invites_spec.rb
index 8ccaf82536a..2ceffa896eb 100644
--- a/spec/features/invites_spec.rb
+++ b/spec/features/invites_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
let(:group_invite) { group.group_members.invite.last }
before do
+ stub_application_setting(require_admin_approval_after_user_signup: false)
project.add_maintainer(owner)
group.add_owner(owner)
group.add_developer('user@example.com', owner)
@@ -58,6 +59,8 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
end
it 'pre-fills the Email field on the sign up box with the invite_email from the invite' do
+ click_link 'Register now'
+
expect(find_field('Email').value).to eq(group_invite.invite_email)
end
@@ -92,6 +95,22 @@ RSpec.describe 'Group or Project invitations', :aggregate_failures do
before do
stub_application_setting(send_user_confirmation_email: send_email_confirmation)
visit invite_path(group_invite.raw_invite_token)
+ click_link 'Register now'
+ end
+
+ context 'with admin appoval required enabled' do
+ before do
+ stub_application_setting(require_admin_approval_after_user_signup: true)
+ end
+
+ let(:send_email_confirmation) { true }
+
+ it 'does not sign the user in' do
+ fill_in_sign_up_form(new_user)
+
+ expect(current_path).to eq(new_user_session_path)
+ expect(page).to have_content('You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your GitLab administrator')
+ end
end
context 'email confirmation disabled' do
diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb
index 6e99cfb3293..867d2ff7aae 100644
--- a/spec/features/issuables/close_reopen_report_toggle_spec.rb
+++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb
@@ -7,6 +7,10 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
let(:user) { create(:user) }
+ before do
+ stub_feature_flags(vue_issue_header: false)
+ end
+
shared_examples 'an issuable close/reopen/report toggle' do
let(:container) { find('.issuable-close-dropdown') }
let(:human_model_name) { issuable.model_name.human.downcase }
@@ -95,12 +99,13 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
expect(page).to have_link('New issue')
expect(page).not_to have_button('Close issue')
expect(page).not_to have_button('Reopen issue')
- expect(page).not_to have_link('Edit')
+ expect(page).not_to have_link(title: 'Edit title and description')
end
end
end
context 'on a merge request' do
+ let(:container) { find('.detail-page-header-actions') }
let(:project) { create(:project, :repository) }
let(:issuable) { create(:merge_request, source_project: project) }
@@ -116,24 +121,47 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
it_behaves_like 'an issuable close/reopen/report toggle'
+ context 'when the merge request is closed' do
+ let(:issuable) { create(:merge_request, :closed, source_project: project) }
+
+ it 'shows both the `Edit` and `Reopen` button' do
+ expect(container).to have_link('Edit')
+ expect(container).not_to have_button('Report abuse')
+ expect(container).not_to have_button('Close merge request')
+ expect(container).to have_link('Reopen merge request')
+ end
+
+ context 'when the merge request author is the current user' do
+ let(:issuable) { create(:merge_request, :closed, source_project: project, author: user) }
+
+ it 'shows both the `Edit` and `Reopen` button' do
+ expect(container).to have_link('Edit')
+ expect(container).not_to have_link('Report abuse')
+ expect(container).not_to have_selector('button.dropdown-toggle')
+ expect(container).not_to have_button('Close merge request')
+ expect(container).to have_link('Reopen merge request')
+ end
+ end
+ end
+
context 'when the merge request is merged' do
let(:issuable) { create(:merge_request, :merged, source_project: project) }
- it 'shows only the `Report abuse` and `Edit` button' do
- expect(page).to have_link('Report abuse')
- expect(page).to have_link('Edit')
- expect(page).not_to have_button('Close merge request')
- expect(page).not_to have_button('Reopen merge request')
+ it 'shows only the `Edit` button' do
+ expect(container).to have_link(exact_text: 'Edit')
+ expect(container).not_to have_link('Report abuse')
+ expect(container).not_to have_button('Close merge request')
+ expect(container).not_to have_button('Reopen merge request')
end
context 'when the merge request author is the current user' do
let(:issuable) { create(:merge_request, :merged, source_project: project, author: user) }
it 'shows only the `Edit` button' do
- expect(page).to have_link('Edit')
- expect(page).to have_link('Report abuse')
- expect(page).not_to have_button('Close merge request')
- expect(page).not_to have_button('Reopen merge request')
+ expect(container).to have_link(exact_text: 'Edit')
+ expect(container).not_to have_link('Report abuse')
+ expect(container).not_to have_button('Close merge request')
+ expect(container).not_to have_button('Reopen merge request')
end
end
end
@@ -150,10 +178,10 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
end
it 'only shows a `Report abuse` button' do
- expect(page).to have_link('Report abuse')
- expect(page).not_to have_button('Close merge request')
- expect(page).not_to have_button('Reopen merge request')
- expect(page).not_to have_link('Edit')
+ expect(container).to have_link('Report abuse')
+ expect(container).not_to have_button('Close merge request')
+ expect(container).not_to have_button('Reopen merge request')
+ expect(container).not_to have_link(exact_text: 'Edit')
end
end
end
diff --git a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
index 12682905559..0f0146a26a2 100644
--- a/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_discussions_in_merge_request_spec.rb
@@ -18,6 +18,10 @@ RSpec.describe 'Resolving all open threads in a merge request from an issue', :j
end
end
+ before do
+ stub_feature_flags(remove_resolve_note: false)
+ end
+
describe 'as a user with access to the project' do
before do
project.add_maintainer(user)
diff --git a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
index 55a02dc4255..b449939a70c 100644
--- a/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
+++ b/spec/features/issues/create_issue_for_single_discussion_in_merge_request_spec.rb
@@ -14,6 +14,10 @@ RSpec.describe 'Resolve an open thread in a merge request by creating an issue',
"a[title=\"#{title}\"][href=\"#{url}\"]"
end
+ before do
+ stub_feature_flags(remove_resolve_note: false)
+ end
+
describe 'As a user with access to the project' do
before do
project.add_maintainer(user)
diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb
index ff78b9e608f..06f79f94e8d 100644
--- a/spec/features/issues/gfm_autocomplete_spec.rb
+++ b/spec/features/issues/gfm_autocomplete_spec.rb
@@ -535,6 +535,21 @@ RSpec.describe 'GFM autocomplete', :js do
expect(find('.tribute-container ul', visible: true)).to have_text(user_xss.username)
end
+ it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
+ milestone_xss_title = 'alert milestone &lt;img src=x onerror="alert(\'Hello xss\');" a'
+ create(:milestone, project: project, title: milestone_xss_title)
+
+ page.within '.timeline-content-form' do
+ find('#note-body').native.send_keys('%')
+ end
+
+ wait_for_requests
+
+ expect(page).to have_selector('.tribute-container', visible: true)
+
+ expect(find('.tribute-container ul', visible: true)).to have_text('alert milestone')
+ end
+
it 'selects the first item for assignee dropdowns' do
page.within '.timeline-content-form' do
find('#note-body').native.send_keys('@')
@@ -799,6 +814,13 @@ RSpec.describe 'GFM autocomplete', :js do
end
end
+ context 'issues' do
+ let(:object) { issue }
+ let(:expected_body) { object.to_reference }
+
+ it_behaves_like 'autocomplete suggestions'
+ end
+
context 'merge requests' do
let(:object) { create(:merge_request, source_project: project) }
let(:expected_body) { object.to_reference }
@@ -806,6 +828,27 @@ RSpec.describe 'GFM autocomplete', :js do
it_behaves_like 'autocomplete suggestions'
end
+ context 'project snippets' do
+ let!(:object) { create(:project_snippet, project: project, title: 'code snippet') }
+ let(:expected_body) { object.to_reference }
+
+ it_behaves_like 'autocomplete suggestions'
+ end
+
+ context 'label' do
+ let!(:object) { label }
+ let(:expected_body) { object.title }
+
+ it_behaves_like 'autocomplete suggestions'
+ end
+
+ context 'milestone' do
+ let!(:object) { create(:milestone, project: project) }
+ let(:expected_body) { object.to_reference }
+
+ it_behaves_like 'autocomplete suggestions'
+ end
+
context 'when other notes are destroyed' do
let!(:discussion) { create(:discussion_note_on_issue, noteable: issue, project: issue.project) }
diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb
index 617eac88973..e225a45481d 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
@@ -250,7 +250,7 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do
def test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request)
page.within(li_create_merge_request) do
- expect(page).to have_css('i.fa.fa-check')
+ expect(page).to have_selector('[data-testid="check-icon"]')
expect(button_create_target).to have_text('Create merge request')
expect(button_create_merge_request).to have_text('Create merge request')
end
@@ -258,7 +258,7 @@ RSpec.describe 'User creates branch and merge request on issue page', :js do
li_create_branch.click
page.within(li_create_branch) do
- expect(page).to have_css('i.fa.fa-check')
+ expect(page).to have_selector('[data-testid="check-icon"]')
expect(button_create_target).to have_text('Create branch')
expect(button_create_merge_request).to have_text('Create branch')
end
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index de746415205..11b905735de 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -138,6 +138,33 @@ RSpec.describe "Issues > User edits issue", :js do
expect(page).not_to have_text('verisimilitude')
end
end
+
+ it 'can remove label without removing label added via quick action', :aggregate_failures do
+ # Add `syzygy` label with a quick action
+ note = find('#note-body')
+ page.within '.timeline-content-form' do
+ note.native.send_keys('/label ~syzygy')
+ end
+ click_button 'Comment'
+
+ wait_for_requests
+
+ page.within '.block.labels' do
+ # Remove `verisimilitude` label
+ within '.gl-label' do
+ click_button
+ end
+
+ wait_for_requests
+
+ expect(page).to have_text('syzygy')
+ expect(page).not_to have_text('verisimilitude')
+ end
+
+ expect(page).to have_text('removed verisimilitude label')
+ expect(page).not_to have_text('removed syzygy verisimilitude labels')
+ expect(issue.reload.labels.map(&:title)).to contain_exactly('syzygy')
+ end
end
describe 'update assignee' do
diff --git a/spec/features/issues/user_interacts_with_awards_spec.rb b/spec/features/issues/user_interacts_with_awards_spec.rb
index 7db72f2cd05..fec603e466a 100644
--- a/spec/features/issues/user_interacts_with_awards_spec.rb
+++ b/spec/features/issues/user_interacts_with_awards_spec.rb
@@ -68,7 +68,7 @@ RSpec.describe 'User interacts with awards' do
page.within('.awards') do
expect(page).to have_selector('.js-emoji-btn')
expect(page.find('.js-emoji-btn.active .js-counter')).to have_content('1')
- expect(page).to have_css(".js-emoji-btn.active[data-original-title='You']")
+ expect(page).to have_css(".js-emoji-btn.active[title='You']")
expect do
page.find('.js-emoji-btn.active').click
@@ -294,7 +294,7 @@ RSpec.describe 'User interacts with awards' do
end
end
- it 'toggles the smiley emoji on a note', :js do
+ it 'toggles the smiley emoji on a note', :js, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/267525' do
toggle_smiley_emoji(true)
within('.note-body') do
diff --git a/spec/features/issues/user_sees_live_update_spec.rb b/spec/features/issues/user_sees_live_update_spec.rb
index d27cdb774a5..79c6978cbc0 100644
--- a/spec/features/issues/user_sees_live_update_spec.rb
+++ b/spec/features/issues/user_sees_live_update_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe 'Issues > User sees live update', :js do
end
describe 'confidential issue#show' do
- it 'shows confidential sibebar information as confidential and can be turned off' do
+ it 'shows confidential sibebar information as confidential and can be turned off', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/254644' do
issue = create(:issue, :confidential, project: project)
visit project_issue_path(project, issue)
diff --git a/spec/features/issues/user_views_issue_spec.rb b/spec/features/issues/user_views_issue_spec.rb
index 9b1c8be1513..4128f3478bb 100644
--- a/spec/features/issues/user_views_issue_spec.rb
+++ b/spec/features/issues/user_views_issue_spec.rb
@@ -13,6 +13,8 @@ RSpec.describe "User views issue" do
end
before do
+ stub_feature_flags(vue_issue_header: false)
+
sign_in(user)
visit(project_issue_path(project, issue))
diff --git a/spec/features/merge_request/user_comments_on_diff_spec.rb b/spec/features/merge_request/user_comments_on_diff_spec.rb
index ad1ad067935..c452408cff2 100644
--- a/spec/features/merge_request/user_comments_on_diff_spec.rb
+++ b/spec/features/merge_request/user_comments_on_diff_spec.rb
@@ -136,12 +136,6 @@ RSpec.describe 'User comments on a diff', :js do
add_comment('-13', '+15')
end
- it 'allows comments to start above hidden lines and end below' do
- # click +28, select 21 add and verify comment
- click_diff_line(find('div[data-path="files/ruby/popen.rb"] .new_line a[data-linenumber="28"]').find(:xpath, '../..'), 'right')
- add_comment('21', '+28')
- end
-
it 'allows comments on previously hidden lines at the top of a file' do
# Click -9, expand up, select 1 add and verify comment
page.within('[data-path="files/ruby/popen.rb"]') do
diff --git a/spec/features/merge_request/user_comments_on_merge_request_spec.rb b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
index 73f2b1a25ce..43096f8e7f9 100644
--- a/spec/features/merge_request/user_comments_on_merge_request_spec.rb
+++ b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
@@ -30,6 +30,27 @@ RSpec.describe 'User comments on a merge request', :js do
end
end
+ it 'replys to a new comment' do
+ page.within('.js-main-target-form') do
+ fill_in('note[note]', with: 'comment 1')
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.note') do
+ click_button('Reply to comment')
+
+ fill_in('note[note]', with: 'comment 2')
+ click_button('Add comment now')
+ end
+
+ wait_for_requests
+
+ # Test that the discussion doesn't get auto-resolved
+ expect(page).to have_button('Resolve thread')
+ end
+
it 'loads new comment' do
# Add new comment in background in order to check
# if it's going to be loaded automatically for current user.
diff --git a/spec/features/merge_request/user_expands_diff_spec.rb b/spec/features/merge_request/user_expands_diff_spec.rb
index 0cdc87de761..09c5897f102 100644
--- a/spec/features/merge_request/user_expands_diff_spec.rb
+++ b/spec/features/merge_request/user_expands_diff_spec.rb
@@ -8,6 +8,8 @@ RSpec.describe 'User expands diff', :js do
before do
stub_feature_flags(increased_diff_limits: false)
+ allow(Gitlab::CurrentSettings).to receive(:diff_max_patch_bytes).and_return(100.kilobytes)
+
visit(diffs_project_merge_request_path(project, merge_request))
wait_for_requests
@@ -15,11 +17,11 @@ RSpec.describe 'User expands diff', :js do
it 'allows user to expand diff' do
page.within find('[id="19763941ab80e8c09871c0a425f0560d9053bcb3"]') do
- click_link 'Click to expand it.'
+ find('[data-testid="expand-button"]').click
wait_for_requests
- expect(page).not_to have_content('Click to expand it.')
+ expect(page).not_to have_content('Expand file')
expect(page).to have_selector('.code')
end
end
diff --git a/spec/features/merge_request/user_merges_immediately_spec.rb b/spec/features/merge_request/user_merges_immediately_spec.rb
index 0fb081ec507..64a357de1f7 100644
--- a/spec/features/merge_request/user_merges_immediately_spec.rb
+++ b/spec/features/merge_request/user_merges_immediately_spec.rb
@@ -34,7 +34,7 @@ RSpec.describe 'Merge requests > User merges immediately', :js do
find('.dropdown-toggle').click
Sidekiq::Testing.fake! do
- click_link 'Merge immediately'
+ click_button 'Merge immediately'
expect(find('.accept-merge-request.btn-info')).to have_content('Merge in progress')
diff --git a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
index 444d5371e7a..5e99383e4a1 100644
--- a/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
+++ b/spec/features/merge_request/user_merges_when_pipeline_succeeds_spec.rb
@@ -93,19 +93,6 @@ RSpec.describe 'Merge request > User merges when pipeline succeeds', :js do
it_behaves_like 'Merge when pipeline succeeds activator'
end
end
-
- describe 'enabling Merge when pipeline succeeds via dropdown' do
- it 'activates the Merge when pipeline succeeds feature' do
- wait_for_requests
-
- find('.js-merge-moment').click
- click_link 'Merge when pipeline succeeds'
-
- expect(page).to have_content "Set by #{user.name} to be merged automatically when the pipeline succeeds"
- expect(page).to have_content "The source branch will not be deleted"
- expect(page).to have_link "Cancel automatic merge"
- end
- end
end
context 'when merge when pipeline succeeds is enabled' do
diff --git a/spec/features/merge_request/user_resolves_conflicts_spec.rb b/spec/features/merge_request/user_resolves_conflicts_spec.rb
index f96408fb10b..06405232462 100644
--- a/spec/features/merge_request/user_resolves_conflicts_spec.rb
+++ b/spec/features/merge_request/user_resolves_conflicts_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe 'Merge request > User resolves conflicts', :js do
+ include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+
let(:project) { create(:project, :repository) }
let(:user) { project.creator }
@@ -64,15 +66,13 @@ RSpec.describe 'Merge request > User resolves conflicts', :js do
within find('.files-wrapper .diff-file', text: 'files/ruby/popen.rb') do
click_button 'Edit inline'
wait_for_requests
- find('.files-wrapper .diff-file pre')
- execute_script('ace.edit($(".files-wrapper .diff-file pre")[0]).setValue("One morning");')
+ editor_set_value("One morning")
end
within find('.files-wrapper .diff-file', text: 'files/ruby/regex.rb') do
click_button 'Edit inline'
wait_for_requests
- find('.files-wrapper .diff-file pre')
- execute_script('ace.edit($(".files-wrapper .diff-file pre")[1]).setValue("Gregor Samsa woke from troubled dreams");')
+ editor_set_value("Gregor Samsa woke from troubled dreams")
end
find_button('Commit to source branch').send_keys(:return)
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 cd06886169d..00f0c88497b 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -15,6 +15,10 @@ RSpec.describe 'Merge request > User resolves diff notes and threads', :js do
diff_refs: merge_request.diff_refs)
end
+ before do
+ stub_feature_flags(remove_resolve_note: false)
+ end
+
context 'no threads' do
before do
project.add_maintainer(user)
diff --git a/spec/features/merge_request/user_resolves_wip_mr_spec.rb b/spec/features/merge_request/user_resolves_wip_mr_spec.rb
index b67167252e1..93b14279a06 100644
--- a/spec/features/merge_request/user_resolves_wip_mr_spec.rb
+++ b/spec/features/merge_request/user_resolves_wip_mr_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Merge request > User resolves Work in Progress', :js do
it 'retains merge request data after clicking Resolve WIP status' do
expect(page.find('.ci-widget-content')).to have_content("Pipeline ##{pipeline.id}")
- expect(page).to have_content "This merge request is still a work in progress."
+ expect(page).to have_content "This merge request is still a draft."
page.within('.mr-state-widget') do
click_button('Mark as ready')
@@ -45,7 +45,7 @@ RSpec.describe 'Merge request > User resolves Work in Progress', :js do
# merge request widget refreshes, which masks missing elements
# that should already be present.
expect(page.find('.ci-widget-content', wait: 0)).to have_content("Pipeline ##{pipeline.id}")
- expect(page).not_to have_content('This merge request is still a work in progress.')
+ expect(page).not_to have_content('This merge request is still a draft.')
end
end
end
diff --git a/spec/features/merge_request/user_sees_merge_widget_spec.rb b/spec/features/merge_request/user_sees_merge_widget_spec.rb
index 93fea44707c..0e8012f161f 100644
--- a/spec/features/merge_request/user_sees_merge_widget_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb
@@ -558,8 +558,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js do
end
before do
- allow_any_instance_of(TestSuiteComparerEntity)
- .to receive(:max_tests).and_return(2)
+ stub_const("Gitlab::Ci::Reports::TestSuiteComparer::DEFAULT_MAX_TESTS", 2)
+ stub_const("Gitlab::Ci::Reports::TestSuiteComparer::DEFAULT_MIN_TESTS", 1)
+
allow_any_instance_of(MergeRequest)
.to receive(:has_test_reports?).and_return(true)
allow_any_instance_of(MergeRequest)
diff --git a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb
index 93807512d9c..4bb6c3265a4 100644
--- a/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb
+++ b/spec/features/merge_request/user_sees_suggest_pipeline_spec.rb
@@ -9,8 +9,6 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do
before do
stub_application_setting(auto_devops_enabled: false)
- stub_experiment(suggest_pipeline: true)
- stub_experiment_for_user(suggest_pipeline: true)
project.add_maintainer(user)
sign_in(user)
visit project_merge_request_path(project, merge_request)
@@ -32,4 +30,40 @@ RSpec.describe 'Merge request > User sees suggest pipeline', :js do
expect(page).not_to have_content('Are you adding technical debt or code vulnerabilities?')
end
+
+ it 'runs tour from start to finish ensuring all nudges are executed' do
+ # nudge 1
+ expect(page).to have_content('Are you adding technical debt or code vulnerabilities?')
+
+ page.within '.mr-pipeline-suggest' do
+ find('[data-testid="ok"]').click
+ end
+
+ wait_for_requests
+
+ # nudge 2
+ expect(page).to have_content('Choose Code Quality to add a pipeline that tests the quality of your code.')
+
+ find('.js-gitlab-ci-yml-selector').click
+
+ wait_for_requests
+
+ within '.gitlab-ci-yml-selector' do
+ find('.dropdown-input-field').set('Jekyll')
+ find('.dropdown-content li', text: 'Jekyll').click
+ end
+
+ wait_for_requests
+
+ expect(page).not_to have_content('Choose Code Quality to add a pipeline that tests the quality of your code.')
+ # nudge 3
+ expect(page).to have_content('The template is ready!')
+
+ find('#commit-changes').click
+
+ wait_for_requests
+
+ # nudge 4
+ expect(page).to have_content("That's it, well done!")
+ end
end
diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
index 9268190c7e0..1e1888cd826 100644
--- a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
+++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
@@ -304,7 +304,7 @@ RSpec.describe 'User comments on a diff', :js do
wait_for_requests
end
- it 'suggestion is presented' do
+ it 'suggestion is presented', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/268240' do
page.within('.diff-discussions') do
expect(page).to have_button('Apply suggestion')
expect(page).to have_content('Suggested change')
diff --git a/spec/features/merge_request/user_views_diffs_spec.rb b/spec/features/merge_request/user_views_diffs_spec.rb
index 928755bf5de..e1865fe2e14 100644
--- a/spec/features/merge_request/user_views_diffs_spec.rb
+++ b/spec/features/merge_request/user_views_diffs_spec.rb
@@ -61,7 +61,7 @@ RSpec.describe 'User views diffs', :js do
end
it 'expands all diffs' do
- first('.js-file-title').click
+ first('.diff-toggle-caret').click
expect(page).to have_button('Expand all')
diff --git a/spec/features/merge_requests/user_exports_as_csv_spec.rb b/spec/features/merge_requests/user_exports_as_csv_spec.rb
new file mode 100644
index 00000000000..a86ff9d7335
--- /dev/null
+++ b/spec/features/merge_requests/user_exports_as_csv_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge Requests > Exports as CSV', :js do
+ let!(:project) { create(:project, :public, :repository) }
+ let!(:user) { project.creator }
+ let!(:open_mr) { create(:merge_request, title: 'Bugfix1', source_project: project, target_project: project, source_branch: 'bugfix1') }
+
+ before do
+ sign_in(user)
+ visit(project_merge_requests_path(project))
+ end
+
+ subject { page.find('.nav-controls') }
+
+ it { is_expected.to have_button('Export as CSV') }
+
+ context 'button is clicked' do
+ before do
+ click_button('Export as CSV')
+ end
+
+ it 'shows a success message' do
+ click_link('Export merge requests')
+
+ expect(page).to have_content 'Your CSV export has started.'
+ expect(page).to have_content "It will be emailed to #{user.email} when complete"
+ end
+ end
+end
diff --git a/spec/features/merge_requests/user_filters_by_draft_spec.rb b/spec/features/merge_requests/user_filters_by_draft_spec.rb
new file mode 100644
index 00000000000..de070805d96
--- /dev/null
+++ b/spec/features/merge_requests/user_filters_by_draft_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Merge Requests > User filters by draft', :js do
+ include FilteredSearchHelpers
+
+ let(:project) { create(:project, :public, :repository) }
+ let(:user) { project.creator }
+
+ before do
+ create(:merge_request, title: 'Draft: Bugfix', source_project: project, target_project: project, source_branch: 'bugfix2')
+
+ sign_in(user)
+ visit project_merge_requests_path(project)
+ end
+
+ it 'filters results' do
+ input_filtered_search_keys('draft:=yes')
+
+ expect(page).to have_content('Draft: Bugfix')
+ end
+
+ it 'does not allow filtering by is not equal' do
+ find('#filtered-search-merge_requests').click
+
+ click_button 'Draft'
+
+ expect(page).not_to have_content('!=')
+ end
+end
diff --git a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb
index 540d87eb969..1d9c80238f5 100644
--- a/spec/features/merge_requests/user_filters_by_target_branch_spec.rb
+++ b/spec/features/merge_requests/user_filters_by_target_branch_spec.rb
@@ -44,4 +44,14 @@ RSpec.describe 'Merge Requests > User filters by target branch', :js do
expect(page).not_to have_content mr2.title
end
end
+
+ context 'filtering by target-branch:!=master' do
+ it 'applies the filter' do
+ input_filtered_search('target-branch:!=master')
+
+ expect(page).to have_issuable_counts(open: 1, closed: 0, all: 1)
+ expect(page).not_to have_content mr1.title
+ expect(page).to have_content mr2.title
+ end
+ end
end
diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb
index fefa2916c30..dce76e4df6d 100644
--- a/spec/features/milestone_spec.rb
+++ b/spec/features/milestone_spec.rb
@@ -76,7 +76,7 @@ RSpec.describe 'Milestone' do
wait_for_requests
- page.within('.time-tracking-no-tracking-pane') do
+ page.within('[data-testid="noTrackingPane"]') do
expect(page).to have_content 'No estimate or time spent'
end
end
@@ -94,7 +94,7 @@ RSpec.describe 'Milestone' do
wait_for_requests
- page.within('.time-tracking-spend-only-pane') do
+ page.within('[data-testid="spentOnlyPane"]') do
expect(page).to have_content 'Spent: 3h'
end
end
diff --git a/spec/features/profile_spec.rb b/spec/features/profile_spec.rb
index 4326700bab1..84ea9495f08 100644
--- a/spec/features/profile_spec.rb
+++ b/spec/features/profile_spec.rb
@@ -12,6 +12,9 @@ RSpec.describe 'Profile account page', :js do
describe 'when I delete my account' do
before do
visit profile_account_path
+
+ # Scroll page to the bottom to make Delete account button visible
+ execute_script('window.scrollTo(0, document.body.scrollHeight)')
end
it { expect(page).to have_content('Delete account') }
@@ -101,10 +104,10 @@ RSpec.describe 'Profile account page', :js do
it 'changes my username' do
fill_in 'username-change-input', with: 'new-username'
- page.find('[data-target="#username-change-confirmation-modal"]').click
+ page.find('[data-testid="username-change-confirmation-modal"]').click
page.within('.modal') do
- find('.js-modal-primary-action').click
+ find('.js-modal-action-primary').click
end
expect(page).to have_content('new-username')
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index e8caa2159a4..62d8a96c1b2 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -33,7 +33,7 @@ RSpec.describe 'Profile > Account', :js do
end
it 'allows the user to disconnect when there is an existing identity' do
- expect(page).to have_link('Disconnect Twitter', href: '/profile/account/unlink?provider=twitter')
+ expect(page).to have_link('Disconnect Twitter', href: '/-/profile/account/unlink?provider=twitter')
end
it 'shows active for a provider that is not allowed to unlink' do
@@ -128,10 +128,10 @@ def update_username(new_username)
fill_in 'username-change-input', with: new_username
- page.find('[data-target="#username-change-confirmation-modal"]').click
+ page.find('[data-testid="username-change-confirmation-modal"]').click
page.within('.modal') do
- find('.js-modal-primary-action').click
+ find('.js-modal-action-primary').click
end
wait_for_requests
diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb
index 4438831fb76..de5a594aca6 100644
--- a/spec/features/profiles/personal_access_tokens_spec.rb
+++ b/spec/features/profiles/personal_access_tokens_spec.rb
@@ -4,6 +4,7 @@ require 'spec_helper'
RSpec.describe 'Profile > Personal Access Tokens', :js do
let(:user) { create(:user) }
+ let(:pat_create_service) { double('PersonalAccessTokens::CreateService', execute: ServiceResponse.error(message: 'error', payload: { personal_access_token: PersonalAccessToken.new })) }
def active_personal_access_tokens
find(".table.active-tokens")
@@ -18,7 +19,7 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
end
def disallow_personal_access_token_saves!
- allow_any_instance_of(PersonalAccessToken).to receive(:save).and_return(false)
+ allow(PersonalAccessTokens::CreateService).to receive(:new).and_return(pat_create_service)
errors = ActiveModel::Errors.new(PersonalAccessToken.new).tap { |e| e.add(:name, "cannot be nil") }
allow_any_instance_of(PersonalAccessToken).to receive(:errors).and_return(errors)
@@ -100,7 +101,10 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do
context "when revocation fails" do
it "displays an error message" do
visit profile_personal_access_tokens_path
- allow_any_instance_of(PersonalAccessTokens::RevokeService).to receive(:revocation_permitted?).and_return(false)
+
+ allow_next_instance_of(PersonalAccessTokens::RevokeService) do |instance|
+ allow(instance).to receive(:revocation_permitted?).and_return(false)
+ end
accept_confirm { click_on "Revoke" }
expect(active_personal_access_tokens).to have_text(personal_access_token.name)
diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb
index 697fccaca34..d0340dfc880 100644
--- a/spec/features/profiles/user_edit_profile_spec.rb
+++ b/spec/features/profiles/user_edit_profile_spec.rb
@@ -20,6 +20,10 @@ RSpec.describe 'User edit profile' do
wait_for_requests
end
+ def toggle_busy_status
+ find('[data-testid="user-availability-checkbox"]').set(true)
+ end
+
it 'changes user profile' do
fill_in 'user_skype', with: 'testskype'
fill_in 'user_linkedin', with: 'testlinkedin'
@@ -180,20 +184,51 @@ RSpec.describe 'User edit profile' do
expect(page).to have_emoji('speech_balloon')
end
end
+
+ it 'sets the users status to busy' do
+ busy_status = find('[data-testid="user-availability-checkbox"]')
+
+ expect(busy_status.checked?).to eq(false)
+
+ toggle_busy_status
+ submit_settings
+ visit profile_path
+
+ expect(busy_status.checked?).to eq(true)
+ end
+
+ context 'with set_user_availability_status feature flag disabled' do
+ before do
+ stub_feature_flags(set_user_availability_status: false)
+ visit root_path(user)
+ end
+
+ it 'does not display the availability checkbox' do
+ expect(page).not_to have_css('[data-testid="user-availability-checkbox"]')
+ end
+ end
end
context 'user menu' do
let(:issue) { create(:issue, project: project)}
let(:project) { create(:project) }
- def open_user_status_modal
+ def open_modal(button_text)
find('.header-user-dropdown-toggle').click
page.within ".header-user" do
- click_button 'Set status'
+ click_button button_text
end
end
+ def open_user_status_modal
+ open_modal 'Set status'
+ end
+
+ def open_edit_status_modal
+ open_modal 'Edit status'
+ end
+
def set_user_status_in_modal
page.within "#set-user-status-modal" do
click_button 'Set status'
@@ -246,6 +281,19 @@ RSpec.describe 'User edit profile' do
end
end
+ it 'sets the users status to busy' do
+ open_user_status_modal
+ busy_status = find('[data-testid="user-availability-checkbox"]')
+
+ expect(busy_status.checked?).to eq(false)
+
+ toggle_busy_status
+ set_user_status_in_modal
+ open_edit_status_modal
+
+ expect(busy_status.checked?).to eq(true)
+ end
+
it 'opens the emoji modal again after closing it' do
open_user_status_modal
select_emoji('biohazard', true)
@@ -307,11 +355,7 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content user_status.message
end
- find('.header-user-dropdown-toggle').click
-
- page.within ".header-user" do
- click_button 'Edit status'
- end
+ open_edit_status_modal
find('.js-clear-user-status-button').click
set_user_status_in_modal
@@ -333,11 +377,7 @@ RSpec.describe 'User edit profile' do
expect(page).to have_content user_status.message
end
- find('.header-user-dropdown-toggle').click
-
- page.within ".header-user" do
- click_button 'Edit status'
- end
+ open_edit_status_modal
page.within "#set-user-status-modal" do
click_button 'Remove status'
@@ -357,6 +397,19 @@ RSpec.describe 'User edit profile' do
expect(page).to have_emoji('speech_balloon')
end
end
+
+ context 'with set_user_availability_status feature flag disabled' do
+ before do
+ stub_feature_flags(set_user_availability_status: false)
+ visit root_path(user)
+ end
+
+ it 'does not display the availability checkbox' do
+ open_user_status_modal
+
+ expect(page).not_to have_css('[data-testid="user-availability-checkbox"]')
+ end
+ end
end
context 'User time preferences', :js do
diff --git a/spec/features/project_group_variables_spec.rb b/spec/features/project_group_variables_spec.rb
index e964a7def14..d8eba20ac18 100644
--- a/spec/features/project_group_variables_spec.rb
+++ b/spec/features/project_group_variables_spec.rb
@@ -24,7 +24,6 @@ RSpec.describe 'Project group variables', :js do
sign_in(user)
project.add_maintainer(user)
group.add_owner(user)
- stub_feature_flags(new_variables_ui: false)
end
it 'project in group shows inherited vars from ancestor group' do
@@ -53,9 +52,13 @@ RSpec.describe 'Project group variables', :js do
it 'project origin keys link to ancestor groups ci_cd settings' do
visit project_path
+
find('.group-origin-link').click
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq(key1)
+
+ wait_for_requests
+
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) [data-label="Key"]').text).to eq(key1)
end
end
end
diff --git a/spec/features/project_variables_spec.rb b/spec/features/project_variables_spec.rb
index c67bcbf919b..a7f94f38d85 100644
--- a/spec/features/project_variables_spec.rb
+++ b/spec/features/project_variables_spec.rb
@@ -12,32 +12,29 @@ RSpec.describe 'Project variables', :js do
sign_in(user)
project.add_maintainer(user)
project.variables << variable
- stub_feature_flags(new_variables_ui: false)
visit page_path
end
it_behaves_like 'variable list'
- it 'adds new variable with a special environment scope' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('somekey')
- find('.js-ci-variable-input-value').set('somevalue')
+ it 'adds a new variable with an environment scope' do
+ click_button('Add Variable')
- find('.js-variable-environment-toggle').click
- find('.js-variable-environment-dropdown-wrapper .dropdown-input-field').set('review/*')
- find('.js-variable-environment-dropdown-wrapper .js-dropdown-create-new-item').click
+ page.within('#add-ci-variable') do
+ find('[data-qa-selector="ci_variable_key_field"] input').set('akey')
+ find('#ci-variable-value').set('akey_value')
+ find('[data-testid="environment-scope"]').click
+ find_button('clear').click
+ find('[data-testid="ci-environment-search"]').set('review/*')
+ find('[data-testid="create-wildcard-button"]').click
- expect(find('input[name="variables[variables_attributes][][environment_scope]"]', visible: false).value).to eq('review/*')
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
-
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('somekey')
- expect(page).to have_content('review/*')
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:first-child [data-label="Environments"]').text).to eq('review/*')
end
end
end
diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb
index c30c8dda852..3f1c10b3688 100644
--- a/spec/features/projects/blobs/edit_spec.rb
+++ b/spec/features/projects/blobs/edit_spec.rb
@@ -179,12 +179,14 @@ RSpec.describe 'Editing file blob', :js do
end
context 'with protected branch' do
- before do
- visit project_edit_blob_path(project, tree_join(protected_branch, file_path))
- end
-
it 'shows blob editor with patch branch' do
- expect(find('.js-branch-name').value).to eq('patch-1')
+ freeze_time do
+ visit project_edit_blob_path(project, tree_join(protected_branch, file_path))
+
+ epoch = Time.now.strftime('%s%L').last(5)
+
+ expect(find('.js-branch-name').value).to eq "#{user.username}-protected-branch-patch-#{epoch}"
+ end
end
end
end
diff --git a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
index fda2992af8d..6b9fd41059d 100644
--- a/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
+++ b/spec/features/projects/blobs/user_creates_new_blob_in_new_project_spec.rb
@@ -23,8 +23,6 @@ RSpec.describe 'User creates new blob', :js do
ide_commit
- click_button('Commit')
-
expect(page).to have_content('All changes are committed')
expect(project.repository.blob_at('master', 'dummy-file').data).to eql("Hello world\n")
end
diff --git a/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb b/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
index 023e00a3e02..3069405ba63 100644
--- a/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
+++ b/spec/features/projects/blobs/user_follows_pipeline_suggest_nudge_spec.rb
@@ -10,7 +10,6 @@ RSpec.describe 'User follows pipeline suggest nudge spec when feature is enabled
describe 'viewing the new blob page' do
before do
- stub_experiment_for_user(suggest_pipeline: true)
sign_in(user)
end
diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb
new file mode 100644
index 00000000000..7012cc6edaa
--- /dev/null
+++ b/spec/features/projects/ci/editor_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Pipeline Editor', :js do
+ include Spec::Support::Helpers::Features::EditorLiteSpecHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ before do
+ sign_in(user)
+ project.add_developer(user)
+
+ visit project_ci_pipeline_editor_path(project)
+ end
+
+ it 'user sees the Pipeline Editor page' do
+ expect(page).to have_content('Pipeline Editor')
+ end
+end
diff --git a/spec/features/projects/ci/lint_spec.rb b/spec/features/projects/ci/lint_spec.rb
index eb2efb4357d..466c7ba215e 100644
--- a/spec/features/projects/ci/lint_spec.rb
+++ b/spec/features/projects/ci/lint_spec.rb
@@ -11,7 +11,6 @@ RSpec.describe 'CI Lint', :js do
let(:content_selector) { '.content .view-lines' }
before do
- stub_feature_flags(ci_lint_vue: false)
project.add_developer(user)
sign_in(user)
@@ -26,7 +25,6 @@ RSpec.describe 'CI Lint', :js do
describe 'YAML parsing' do
shared_examples 'validates the YAML' do
before do
- stub_feature_flags(ci_lint_vue: false)
click_on 'Validate'
end
@@ -68,14 +66,6 @@ RSpec.describe 'CI Lint', :js do
it_behaves_like 'validates the YAML'
end
-
- describe 'YAML revalidate' do
- let(:yaml_content) { 'my yaml content' }
-
- it 'loads previous YAML content after validation' do
- expect(page).to have_field('content', with: 'my yaml content', visible: false, type: 'textarea')
- end
- end
end
describe 'YAML clearing' do
@@ -89,7 +79,7 @@ RSpec.describe 'CI Lint', :js do
end
it 'YAML content is cleared' do
- expect(page).to have_field('content', with: '', visible: false, type: 'textarea')
+ expect(page).to have_field(class: 'inputarea', with: '', visible: false, type: 'textarea')
end
end
end
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index 7514a26f020..45bf35a6aab 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe 'Container Registry', :js do
create(:container_repository, name: 'my/image')
end
+ let(:nameless_container_repository) do
+ create(:container_repository, name: '')
+ end
+
before do
sign_in(user)
project.add_developer(user)
@@ -96,6 +100,20 @@ RSpec.describe 'Container Registry', :js do
end
end
+ describe 'image repo details when image has no name' do
+ before do
+ stub_container_registry_tags(tags: %w[latest], with_manifest: true)
+ project.container_repositories << nameless_container_repository
+ visit_container_registry
+ end
+
+ it 'renders correctly' do
+ find('a[data-testid="details-link"]').click
+
+ expect(page).to have_content 'latest'
+ end
+ end
+
context 'when there are more than 10 images' do
before do
create_list(:container_repository, 12, project: project)
diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
index d28e31c08dc..42f8daf9d5e 100644
--- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
+++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb
@@ -27,9 +27,7 @@ RSpec.describe 'Projects > Files > Project owner sees a link to create a license
ide_commit
- click_button('Commit')
-
- expect(current_path).to eq("/-/ide/project/#{project.full_path}/tree/master/-/")
+ expect(current_path).to eq("/-/ide/project/#{project.full_path}/tree/master/-/LICENSE/")
expect(page).to have_content('All changes are committed')
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index a6126fbcb33..4e9e129042c 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -87,26 +87,22 @@ RSpec.describe "User browses files" do
end
it "shows correct files and links" do
- # rubocop:disable Lint/Void
- # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`.
- find("a", text: /^empty$/)["href"] == project_tree_url(project, "markdown")
- find("a", text: /^#id$/)["href"] == project_tree_url(project, "markdown", anchor: "#id")
- find("a", text: %r{^/#id$})["href"] == project_tree_url(project, "markdown", anchor: "#id")
- find("a", text: /^README.md#id$/)["href"] == project_blob_url(project, "markdown/README.md", anchor: "#id")
- find("a", text: %r{^d/README.md#id$})["href"] == project_blob_url(project, "d/markdown/README.md", anchor: "#id")
- # rubocop:enable Lint/Void
-
expect(current_path).to eq(project_tree_path(project, "markdown"))
expect(page).to have_content("README.md")
- .and have_content("CHANGELOG")
- .and have_content("Welcome to GitLab GitLab is a free project and repository management application")
- .and have_link("GitLab API doc")
- .and have_link("GitLab API website")
- .and have_link("Rake tasks")
- .and have_link("backup and restore procedure")
- .and have_link("GitLab API doc directory")
- .and have_link("Maintenance")
- .and have_header_with_correct_id_and_link(2, "Application details", "application-details")
+ .and have_content("CHANGELOG")
+ .and have_content("Welcome to GitLab GitLab is a free project and repository management application")
+ .and have_link("GitLab API doc")
+ .and have_link("GitLab API website")
+ .and have_link("Rake tasks")
+ .and have_link("backup and restore procedure")
+ .and have_link("GitLab API doc directory")
+ .and have_link("Maintenance")
+ .and have_header_with_correct_id_and_link(2, "Application details", "application-details")
+ .and have_link("empty", href: "")
+ .and have_link("#id", href: "#id")
+ .and have_link("/#id", href: project_blob_path(project, "markdown/README.md", anchor: "id"))
+ .and have_link("README.md#id", href: project_blob_path(project, "markdown/README.md", anchor: "id"))
+ .and have_link("d/README.md#id", href: project_blob_path(project, "markdown/db/README.md", anchor: "id"))
end
it "shows correct content of file" do
@@ -114,10 +110,10 @@ RSpec.describe "User browses files" do
expect(current_path).to eq(project_blob_path(project, "markdown/doc/api/README.md"))
expect(page).to have_content("All API requests require authentication")
- .and have_content("Contents")
- .and have_link("Users")
- .and have_link("Rake tasks")
- .and have_header_with_correct_id_and_link(1, "GitLab API", "gitlab-api")
+ .and have_content("Contents")
+ .and have_link("Users")
+ .and have_link("Rake tasks")
+ .and have_header_with_correct_id_and_link(1, "GitLab API", "gitlab-api")
click_link("Users")
@@ -148,16 +144,13 @@ RSpec.describe "User browses files" do
click_link("d")
end
- # rubocop:disable Lint/Void
- # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`.
- find("a", text: "..")["href"] == project_tree_url(project, "markdown/d")
- # rubocop:enable Lint/Void
+ expect(page).to have_link("..", href: project_tree_path(project, "markdown/"))
page.within(".tree-table") do
click_link("README.md")
end
- # Test the full URLs of links instead of relative paths by `have_link(text: "...", href: "...")`.
- find("a", text: /^empty$/)["href"] == project_blob_url(project, "markdown/d/README.md")
+
+ expect(page).to have_link("empty", href: "")
end
it "shows correct content of directory" do
diff --git a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
index de1fcc9d787..b5d5527bbfe 100644
--- a/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
+++ b/spec/features/projects/issues/design_management/user_uploads_designs_spec.rb
@@ -51,7 +51,7 @@ RSpec.describe 'User uploads new design', :js do
end
def upload_design(fixture, count:)
- attach_file(:design_file, fixture, match: :first, make_visible: true)
+ attach_file(:upload_file, fixture, match: :first, make_visible: true)
wait_for('designs uploaded') do
issue.reload.designs.count == count
diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb
index 404c3e93586..e19337e1ff5 100644
--- a/spec/features/projects/jobs_spec.rb
+++ b/spec/features/projects/jobs_spec.rb
@@ -1013,7 +1013,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
before do
job.run!
visit project_job_path(project, job)
- find('.js-cancel-job').click
+ find('[data-testid="cancel-button"]').click
end
it 'loads the page and shows all needed controls' do
@@ -1030,7 +1030,7 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
visit project_job_path(project, job)
wait_for_requests
- find('.js-retry-button').click
+ find('[data-testid="retry-button"]').click
end
it 'shows the right status and buttons' do
@@ -1057,6 +1057,31 @@ RSpec.describe 'Jobs', :clean_gitlab_redis_shared_state do
end
end
end
+
+ context "Job that failed because of a forward deployment failure" do
+ let(:job) { create(:ci_build, :forward_deployment_failure, pipeline: pipeline) }
+
+ before do
+ visit project_job_path(project, job)
+ wait_for_requests
+
+ find('[data-testid="retry-button"]').click
+ end
+
+ it 'shows a modal to warn the user' do
+ page.within('.modal-header') do
+ expect(page).to have_content 'Are you sure you want to retry this job?'
+ end
+ end
+
+ it 'retries the job' do
+ find('[data-testid="retry-button-modal"]').click
+
+ within '[data-testid="ci-header-content"]' do
+ expect(page).to have_content('pending')
+ end
+ end
+ end
end
describe "GET /:project/jobs/:id/download", :js do
diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb
index 4ff3827b240..25791b393bc 100644
--- a/spec/features/projects/navbar_spec.rb
+++ b/spec/features/projects/navbar_spec.rb
@@ -67,4 +67,23 @@ RSpec.describe 'Project navbar' do
it_behaves_like 'verified navigation bar'
end
+
+ context 'when invite team members is not available' do
+ it 'does not display the js-invite-members-trigger' do
+ visit project_path(project)
+
+ expect(page).not_to have_selector('.js-invite-members-trigger')
+ end
+ end
+
+ context 'when invite team members is available' do
+ it 'includes the div for js-invite-members-trigger' do
+ stub_feature_flags(invite_members_group_modal: true)
+ allow_any_instance_of(InviteMembersHelper).to receive(:invite_members_allowed?).and_return(true)
+
+ visit project_path(project)
+
+ expect(page).to have_selector('.js-invite-members-trigger')
+ end
+ end
end
diff --git a/spec/features/projects/pages_spec.rb b/spec/features/projects/pages_spec.rb
index c3eea0195a6..11f712fde81 100644
--- a/spec/features/projects/pages_spec.rb
+++ b/spec/features/projects/pages_spec.rb
@@ -365,7 +365,7 @@ RSpec.shared_examples 'pages settings editing' do
end
let!(:artifact) do
- create(:ci_job_artifact, :archive,
+ create(:ci_job_artifact, :archive, :correct_checksum,
file: fixture_file_upload(File.join('spec/fixtures/pages.zip')), job: ci_build)
end
diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb
index 1e2cd3c0a69..94e3331b173 100644
--- a/spec/features/projects/pipeline_schedules_spec.rb
+++ b/spec/features/projects/pipeline_schedules_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe 'Pipeline Schedules', :js do
it 'displays the required information description' do
page.within('.pipeline-schedule-table-row') do
expect(page).to have_content('pipeline schedule')
- expect(find(".next-run-cell time")['data-original-title'])
+ expect(find(".next-run-cell time")['title'])
.to include(pipeline_schedule.real_next_run.strftime('%b %-d, %Y'))
expect(page).to have_link('master')
expect(page).to have_link("##{pipeline.id}")
diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb
index 51826d867cd..ac3566fbbdd 100644
--- a/spec/features/projects/pipelines/pipeline_spec.rb
+++ b/spec/features/projects/pipelines/pipeline_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Pipeline', :js do
before do
sign_in(user)
project.add_role(user, role)
+ stub_feature_flags(graphql_pipeline_details: false)
end
shared_context 'pipeline builds' do
@@ -345,7 +346,7 @@ RSpec.describe 'Pipeline', :js 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('Needs')
expect(page).to have_link('Failed Jobs')
end
@@ -892,7 +893,7 @@ RSpec.describe 'Pipeline', :js 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')
+ expect(page).to have_link('Needs')
end
it 'shows counter in Jobs tab' do
diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb
index 3e78dfc3bc7..450524b8d70 100644
--- a/spec/features/projects/pipelines/pipelines_spec.rb
+++ b/spec/features/projects/pipelines/pipelines_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe 'Pipelines', :js do
before do
sign_in(user)
+ stub_feature_flags(graphql_pipeline_details: false)
project.add_developer(user)
project.update!(auto_devops_attributes: { enabled: false })
end
diff --git a/spec/features/projects/releases/user_views_edit_release_spec.rb b/spec/features/projects/releases/user_views_edit_release_spec.rb
index 9115a135aeb..bb54b6be9c4 100644
--- a/spec/features/projects/releases/user_views_edit_release_spec.rb
+++ b/spec/features/projects/releases/user_views_edit_release_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'User edits Release', :js do
let_it_be(:project) { create(:project, :repository) }
- let_it_be(:release) { create(:release, project: project, name: 'The first release' ) }
+ let_it_be(:release) { create(:release, :with_milestones, milestones_count: 1, project: project, name: 'The first release' ) }
let_it_be(:user) { create(:user) }
before do
diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb
index 323c57570c3..aabbc8cea7b 100644
--- a/spec/features/projects/releases/user_views_releases_spec.rb
+++ b/spec/features/projects/releases/user_views_releases_spec.rb
@@ -3,8 +3,14 @@
require 'spec_helper'
RSpec.describe 'User views releases', :js do
+ let_it_be(:today) { Time.zone.now }
+ let_it_be(:yesterday) { today - 1.day }
+ let_it_be(:tomorrow) { today + 1.day }
+
let_it_be(:project) { create(:project, :repository, :private) }
- let_it_be(:release) { create(:release, project: project, name: 'The first release' ) }
+ let_it_be(:release_v1) { create(:release, project: project, tag: 'v1', name: 'The first release', released_at: yesterday, created_at: today) }
+ let_it_be(:release_v2) { create(:release, project: project, tag: 'v2-with-a/slash', name: 'The second release', released_at: today, created_at: yesterday) }
+ let_it_be(:release_v3) { create(:release, project: project, tag: 'v3', name: 'The third release', released_at: tomorrow, created_at: tomorrow) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
@@ -17,38 +23,36 @@ RSpec.describe 'User views releases', :js do
context('when the user is a maintainer') do
before do
sign_in(maintainer)
- end
- it 'sees the release' do
visit project_releases_path(project)
+ end
- expect(page).to have_content(release.name)
- expect(page).to have_content(release.tag)
- expect(page).not_to have_content('Upcoming Release')
+ it 'sees the release' do
+ page.within("##{release_v1.tag}") do
+ expect(page).to have_content(release_v1.name)
+ expect(page).to have_content(release_v1.tag)
+ expect(page).not_to have_content('Upcoming Release')
+ end
end
context 'when there is a link as an asset' do
- let!(:release_link) { create(:release_link, release: release, url: url ) }
+ let!(:release_link) { create(:release_link, release: release_v1, url: url ) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
- let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
+ let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{release_link.filepath}" }
it 'sees the link' do
- visit project_releases_path(project)
-
- page.within('.js-assets-list') do
+ page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
end
context 'when there is a link redirect' do
- let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
+ let!(:release_link) { create(:release_link, release: release_v1, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
it 'sees the link' do
- visit project_releases_path(project)
-
- page.within('.js-assets-list') do
+ page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
@@ -59,9 +63,7 @@ RSpec.describe 'User views releases', :js do
let(:url) { 'http://google.com/download' }
it 'sees that the link is external resource' do
- visit project_releases_path(project)
-
- page.within('.js-assets-list') do
+ page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_css('[data-testid="external-link-indicator"]')
end
end
@@ -69,23 +71,57 @@ RSpec.describe 'User views releases', :js do
end
context 'with an upcoming release' do
- let(:tomorrow) { Time.zone.now + 1.day }
- let!(:release) { create(:release, project: project, released_at: tomorrow ) }
-
it 'sees the upcoming tag' do
- visit project_releases_path(project)
-
- expect(page).to have_content('Upcoming Release')
+ page.within("##{release_v3.tag}") do
+ expect(page).to have_content('Upcoming Release')
+ end
end
end
context 'with a tag containing a slash' do
it 'sees the release' do
- release = create :release, project: project, tag: 'debian/2.4.0-1'
- visit project_releases_path(project)
+ page.within("##{release_v2.tag.parameterize}") do
+ expect(page).to have_content(release_v2.name)
+ expect(page).to have_content(release_v2.tag)
+ end
+ end
+ end
+
+ context 'sorting' do
+ def sort_page(by:, direction:)
+ within '[data-testid="releases-sort"]' do
+ find('.dropdown-toggle').click
+
+ click_button(by, class: 'dropdown-item')
+
+ find('.sorting-direction-button').click if direction == :ascending
+ end
+ end
+
+ shared_examples 'releases sort order' do
+ it "sorts the releases #{description}" do
+ card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
+
+ card_titles.each_with_index do |title, index|
+ expect(title).to have_content(expected_releases[index].name)
+ end
+ end
+ end
+
+ context "when the page is sorted by the default sort order" do
+ let(:expected_releases) { [release_v3, release_v2, release_v1] }
+
+ it_behaves_like 'releases sort order'
+ end
+
+ context "when the page is sorted by created_at ascending " do
+ let(:expected_releases) { [release_v2, release_v1, release_v3] }
+
+ before do
+ sort_page by: 'Created date', direction: :ascending
+ end
- expect(page).to have_content(release.name)
- expect(page).to have_content(release.tag)
+ it_behaves_like 'releases sort order'
end
end
end
@@ -98,14 +134,14 @@ RSpec.describe 'User views releases', :js do
it 'renders release info except for Git-related data' do
visit project_releases_path(project)
- within('.release-block') do
- expect(page).to have_content(release.description)
+ within('.release-block', match: :first) do
+ expect(page).to have_content(release_v3.description)
# The following properties (sometimes) include Git info,
# so they are not rendered for Guest users
- expect(page).not_to have_content(release.name)
- expect(page).not_to have_content(release.tag)
- expect(page).not_to have_content(release.commit.short_id)
+ expect(page).not_to have_content(release_v3.name)
+ expect(page).not_to have_content(release_v3.tag)
+ expect(page).not_to have_content(release_v3.commit.short_id)
end
end
end
diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb
index 4e1b53ffc87..cb333bdb428 100644
--- a/spec/features/projects/settings/registry_settings_spec.rb
+++ b/spec/features/projects/settings/registry_settings_spec.rb
@@ -15,10 +15,10 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
before do
project.update!(container_registry_enabled: container_registry_enabled_on_project)
+ project.container_expiration_policy.update!(enabled: true)
sign_in(user)
stub_container_registry_config(enabled: container_registry_enabled)
- stub_feature_flags(new_variables_ui: false)
end
context 'as owner' do
@@ -33,13 +33,13 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
subject
within '#js-registry-policies' do
- within '.card-body' do
+ within '.gl-card-body' do
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('Tags with names matching this regex pattern will expire:', with: '.*-production')
end
- submit_button = find('.card-footer .btn.btn-success')
+ submit_button = find('.gl-card-footer .btn.btn-success')
expect(submit_button).not_to be_disabled
submit_button.click
end
@@ -51,10 +51,10 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
subject
within '#js-registry-policies' do
- within '.card-body' do
+ within '.gl-card-body' do
fill_in('Tags with names matching this regex pattern will expire:', with: '*-production')
end
- submit_button = find('.card-footer .btn.btn-success')
+ submit_button = find('.gl-card-footer .btn.btn-success')
expect(submit_button).not_to be_disabled
submit_button.click
end
@@ -85,7 +85,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
within '#js-registry-policies' do
case result
when :available_section
- expect(find('.card-header')).to have_content('Tag expiration policy')
+ expect(find('.gl-card-header')).to have_content('Tag expiration policy')
when :disabled_message
expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled')
end
diff --git a/spec/features/projects/settings/service_desk_setting_spec.rb b/spec/features/projects/settings/service_desk_setting_spec.rb
index 7856ab1fb4e..59e6f54da2f 100644
--- a/spec/features/projects/settings/service_desk_setting_spec.rb
+++ b/spec/features/projects/settings/service_desk_setting_spec.rb
@@ -28,6 +28,6 @@ RSpec.describe 'Service Desk Setting', :js do
project.reload
expect(project.service_desk_enabled).to be_truthy
expect(project.service_desk_address).to be_present
- expect(find('.incoming-email').value).to eq(project.service_desk_address)
+ expect(find('[data-testid="incoming-email"]').value).to eq(project.service_desk_address)
end
end
diff --git a/spec/features/projects/settings/webhooks_settings_spec.rb b/spec/features/projects/settings/webhooks_settings_spec.rb
index d184f08bd89..528fd58cbe6 100644
--- a/spec/features/projects/settings/webhooks_settings_spec.rb
+++ b/spec/features/projects/settings/webhooks_settings_spec.rb
@@ -45,6 +45,7 @@ RSpec.describe 'Projects > Settings > Webhook Settings' do
expect(page).to have_content('Merge requests events')
expect(page).to have_content('Pipeline events')
expect(page).to have_content('Wiki page events')
+ expect(page).to have_content('Releases events')
end
it 'create webhook' do
diff --git a/spec/features/projects/show/schema_markup_spec.rb b/spec/features/projects/show/schema_markup_spec.rb
new file mode 100644
index 00000000000..e651798af23
--- /dev/null
+++ b/spec/features/projects/show/schema_markup_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Projects > Show > Schema Markup' do
+ let_it_be(:project) { create(:project, :repository, :public, :with_avatar, description: 'foobar', tag_list: 'tag1, tag2') }
+
+ it 'shows SoftwareSourceCode structured markup', :js do
+ visit project_path(project)
+ wait_for_all_requests
+
+ aggregate_failures do
+ expect(page).to have_selector('[itemscope][itemtype="http://schema.org/SoftwareSourceCode"]')
+ expect(page).to have_selector('img[itemprop="image"]')
+ expect(page).to have_selector('[itemprop="name"]', text: project.name)
+ expect(page).to have_selector('[itemprop="identifier"]', text: "Project ID: #{project.id}")
+ expect(page).to have_selector('[itemprop="abstract"]', text: project.description)
+ expect(page).to have_selector('[itemprop="license"]', text: project.repository.license.name)
+ expect(find_all('[itemprop="keywords"]').map(&:text)).to match_array(project.tag_list.map(&:capitalize))
+ expect(page).to have_selector('[itemprop="about"]')
+ end
+ end
+end
diff --git a/spec/features/projects/snippets/create_snippet_spec.rb b/spec/features/projects/snippets/create_snippet_spec.rb
index 28fe0a0b7e1..0ed9e23c7f8 100644
--- a/spec/features/projects/snippets/create_snippet_spec.rb
+++ b/spec/features/projects/snippets/create_snippet_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe 'Projects > Snippets > Create Snippet', :js do
click_button('Create snippet')
wait_for_requests
- link = find('a.no-attachment-icon img[alt="banana_sample"]')['src']
+ link = find('a.no-attachment-icon img.js-lazy-loaded[alt="banana_sample"]')['src']
expect(link).to match(%r{/#{Regexp.escape(project.full_path)}/uploads/\h{32}/banana_sample\.gif\z})
end
diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb
new file mode 100644
index 00000000000..2680dfb2b13
--- /dev/null
+++ b/spec/features/projects/terraform_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Terraform', :js do
+ let_it_be(:project) { create(:project) }
+
+ let(:user) { project.creator }
+
+ before do
+ gitlab_sign_in(user)
+ end
+
+ context 'when user does not have any terraform states and visits index page' do
+ before do
+ visit project_terraform_index_path(project)
+ end
+
+ it 'sees an empty state' do
+ expect(page).to have_content('Get started with Terraform')
+ end
+ end
+
+ context 'when user has a terraform state' do
+ let_it_be(:terraform_state) { create(:terraform_state, :locked, project: project) }
+
+ context 'when user visits the index page' do
+ before do
+ visit project_terraform_index_path(project)
+ end
+
+ it 'displays a tab with states count' do
+ expect(page).to have_content("States #{project.terraform_states.size}")
+ end
+
+ it 'displays a table with terraform states' do
+ expect(page).to have_selector(
+ '[data-testid="terraform-states-table"] tbody tr',
+ count: project.terraform_states.size
+ )
+ end
+
+ it 'displays terraform information' do
+ expect(page).to have_content(terraform_state.name)
+ end
+ end
+ end
+end
diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb
index 6baeb4ce368..9b5f4ca6d48 100644
--- a/spec/features/projects_spec.rb
+++ b/spec/features/projects_spec.rb
@@ -279,7 +279,7 @@ RSpec.describe 'Project' do
end
it 'deletes a project', :sidekiq_might_not_need_inline do
- expect { remove_with_confirm('Delete project', "Delete #{project.full_name}", 'Yes, delete project') }.to change { Project.count }.by(-1)
+ expect { remove_with_confirm('Delete project', project.path, 'Yes, delete project') }.to change { Project.count }.by(-1)
expect(page).to have_content "Project '#{project.full_name}' is in the process of being deleted."
expect(Project.all.count).to be_zero
expect(project.issues).to be_empty
diff --git a/spec/features/read_only_spec.rb b/spec/features/read_only_spec.rb
index eb043d2193a..11686552062 100644
--- a/spec/features/read_only_spec.rb
+++ b/spec/features/read_only_spec.rb
@@ -9,19 +9,19 @@ RSpec.describe 'read-only message' do
sign_in(user)
end
- it 'shows read-only banner when database is read-only' do
- allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+ context 'when database is read-only' do
+ before do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+ end
- visit root_dashboard_path
-
- expect(page).to have_content('You are on a read-only GitLab instance.')
+ it_behaves_like 'Read-only instance', /You are on a read\-only GitLab instance./
end
- it 'does not show read-only banner when database is able to read-write' do
- allow(Gitlab::Database).to receive(:read_only?).and_return(false)
-
- visit root_dashboard_path
+ context 'when database is in read-write mode' do
+ before do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(false)
+ end
- expect(page).not_to have_content('You are on a read-only GitLab instance.')
+ it_behaves_like 'Read-write instance', /You are on a read\-only GitLab instance./
end
end
diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb
index 6e18de3be7b..9697e10c3d1 100644
--- a/spec/features/runners_spec.rb
+++ b/spec/features/runners_spec.rb
@@ -122,6 +122,19 @@ RSpec.describe 'Runners' do
end
end
+ context 'when multiple runners are configured' do
+ let!(:specific_runner) { create(:ci_runner, :project, projects: [project]) }
+ let!(:specific_runner_2) { create(:ci_runner, :project, projects: [project]) }
+
+ it 'adds pagination to the runner list' do
+ stub_const('Projects::Settings::CiCdController::NUMBER_OF_RUNNERS_PER_PAGE', 1)
+
+ visit project_runners_path(project)
+
+ expect(find('.pagination')).not_to be_nil
+ end
+ end
+
context 'when a specific runner exists in another project' do
let(:another_project) { create(:project) }
let!(:specific_runner) { create(:ci_runner, :project, projects: [another_project]) }
diff --git a/spec/features/search/user_searches_for_code_spec.rb b/spec/features/search/user_searches_for_code_spec.rb
index a88043c98ac..f761bd30baf 100644
--- a/spec/features/search/user_searches_for_code_spec.rb
+++ b/spec/features/search/user_searches_for_code_spec.rb
@@ -28,10 +28,7 @@ RSpec.describe 'User searches for code' do
before do
visit(search_path)
find('.js-search-project-dropdown').click
-
- page.within('.project-filter') do
- click_link(project.full_name)
- end
+ find('[data-testid="project-filter"]').click_link(project.full_name)
end
include_examples 'top right search form'
diff --git a/spec/features/search/user_searches_for_issues_spec.rb b/spec/features/search/user_searches_for_issues_spec.rb
index 900ed35adea..e2ae2738d2f 100644
--- a/spec/features/search/user_searches_for_issues_spec.rb
+++ b/spec/features/search/user_searches_for_issues_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
RSpec.describe 'User searches for issues', :js do
let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) }
- let!(:issue1) { create(:issue, title: 'Foo', project: project) }
- let!(:issue2) { create(:issue, :closed, :confidential, title: 'Bar', project: project) }
+ let!(:issue1) { create(:issue, title: 'issue Foo', project: project, created_at: 1.hour.ago) }
+ let!(:issue2) { create(:issue, :closed, :confidential, title: 'issue Bar', project: project) }
def search_for_issue(search)
fill_in('dashboard_search', with: search)
@@ -67,13 +67,26 @@ RSpec.describe 'User searches for issues', :js do
end
end
+ it 'sorts by created date' do
+ search_for_issue('issue')
+
+ page.within('.results') do
+ expect(page.all('.search-result-row').first).to have_link(issue2.title)
+ expect(page.all('.search-result-row').last).to have_link(issue1.title)
+ end
+
+ find('.reverse-sort-btn').click
+
+ page.within('.results') do
+ expect(page.all('.search-result-row').first).to have_link(issue1.title)
+ expect(page.all('.search-result-row').last).to have_link(issue2.title)
+ end
+ end
+
context 'when on a project page' do
it 'finds an issue' do
find('.js-search-project-dropdown').click
-
- page.within('.project-filter') do
- click_link(project.full_name)
- end
+ find('[data-testid="project-filter"]').click_link(project.full_name)
search_for_issue(issue1.title)
diff --git a/spec/features/search/user_searches_for_merge_requests_spec.rb b/spec/features/search/user_searches_for_merge_requests_spec.rb
index 40583664958..6f8f6303b66 100644
--- a/spec/features/search/user_searches_for_merge_requests_spec.rb
+++ b/spec/features/search/user_searches_for_merge_requests_spec.rb
@@ -31,10 +31,7 @@ RSpec.describe 'User searches for merge requests', :js do
context 'when on a project page' do
it 'finds a merge request' do
find('.js-search-project-dropdown').click
-
- page.within('.project-filter') do
- click_link(project.full_name)
- end
+ find('[data-testid="project-filter"]').click_link(project.full_name)
fill_in('dashboard_search', with: merge_request1.title)
find('.btn-search').click
diff --git a/spec/features/search/user_searches_for_milestones_spec.rb b/spec/features/search/user_searches_for_milestones_spec.rb
index 64e756db180..1a2227db214 100644
--- a/spec/features/search/user_searches_for_milestones_spec.rb
+++ b/spec/features/search/user_searches_for_milestones_spec.rb
@@ -31,10 +31,7 @@ RSpec.describe 'User searches for milestones', :js do
context 'when on a project page' do
it 'finds a milestone' do
find('.js-search-project-dropdown').click
-
- page.within('.project-filter') do
- click_link(project.full_name)
- end
+ find('[data-testid="project-filter"]').click_link(project.full_name)
fill_in('dashboard_search', with: milestone1.title)
find('.btn-search').click
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 fc60b6244d9..6bf1407fd4f 100644
--- a/spec/features/search/user_searches_for_wiki_pages_spec.rb
+++ b/spec/features/search/user_searches_for_wiki_pages_spec.rb
@@ -19,10 +19,7 @@ RSpec.describe 'User searches for wiki pages', :js do
shared_examples 'search wiki blobs' do
it 'finds a page' do
find('.js-search-project-dropdown').click
-
- page.within('.project-filter') do
- click_link(project.full_name)
- end
+ find('[data-testid="project-filter"]').click_link(project.full_name)
fill_in('dashboard_search', with: search_term)
find('.btn-search').click
diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb
index 5cbfacf4e48..9296a3f33d4 100644
--- a/spec/features/search/user_uses_header_search_field_spec.rb
+++ b/spec/features/search/user_uses_header_search_field_spec.rb
@@ -5,11 +5,18 @@ require 'spec_helper'
RSpec.describe 'User uses header search field', :js do
include FilteredSearchHelpers
- let(:project) { create(:project) }
- let(:user) { create(:user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ let(:user) { reporter }
+
+ before_all do
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+ end
before do
- project.add_reporter(user)
sign_in(user)
end
@@ -34,7 +41,7 @@ RSpec.describe 'User uses header search field', :js do
wait_for_all_requests
end
- it 'shows the category search dropdown' do
+ it 'shows the category search dropdown', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/250285' do
expect(page).to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
end
@@ -44,7 +51,7 @@ RSpec.describe 'User uses header search field', :js do
page.find('#search').click
end
- it 'shows category search dropdown' do
+ it 'shows category search dropdown', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/250285' do
expect(page).to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
@@ -104,7 +111,7 @@ RSpec.describe 'User uses header search field', :js do
let(:scope_name) { 'All GitLab' }
end
- it 'displays search options' do
+ it 'displays search options', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/251076' do
fill_in_search('test')
expect(page).to have_selector(scoped_search_link('test'))
@@ -132,6 +139,10 @@ RSpec.describe 'User uses header search field', :js do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
+ before do
+ project.add_reporter(user)
+ end
+
include_examples 'search field examples' do
let(:url) { project_path(project) }
let(:scope_name) { project.name }
@@ -159,6 +170,35 @@ RSpec.describe 'User uses header search field', :js do
expect(page).not_to have_selector(scoped_search_link('test', group_id: project.namespace_id))
expect(page).to have_selector(scoped_search_link('test', project_id: project.id))
end
+
+ it 'displays a link to project merge requests' do
+ fill_in_search('Merge')
+
+ within(dashboard_search_options_popup_menu) do
+ expect(page).to have_text('Merge Requests')
+ end
+ end
+
+ it 'does not display a link to project feature flags' do
+ fill_in_search('Feature')
+
+ within(dashboard_search_options_popup_menu) do
+ expect(page).to have_text('"Feature" in all GitLab')
+ expect(page).to have_no_text('Feature Flags')
+ end
+ end
+
+ context 'and user is a developer' do
+ let(:user) { developer }
+
+ it 'displays a link to project feature flags' do
+ fill_in_search('Feature')
+
+ within(dashboard_search_options_popup_menu) do
+ expect(page).to have_text('Feature Flags')
+ end
+ end
+ end
end
end
@@ -217,4 +257,8 @@ RSpec.describe 'User uses header search field', :js do
".dropdown a[href='#{href}']"
end
+
+ def dashboard_search_options_popup_menu
+ "div[data-testid='dashboard-search-options']"
+ end
end
diff --git a/spec/features/search/user_uses_search_filters_spec.rb b/spec/features/search/user_uses_search_filters_spec.rb
index 080cced21c3..bd77e6003e3 100644
--- a/spec/features/search/user_uses_search_filters_spec.rb
+++ b/spec/features/search/user_uses_search_filters_spec.rb
@@ -18,17 +18,17 @@ RSpec.describe 'User uses search filters', :js do
it 'shows group projects' do
visit search_path
- find('.js-search-group-dropdown').click
+ find('[data-testid="group-filter"]').click
wait_for_requests
- page.within('.search-page-form') do
- click_link(group.name)
+ page.within('[data-testid="group-filter"]') do
+ click_on(group.name)
end
- expect(find('.js-search-group-dropdown')).to have_content(group.name)
+ expect(find('[data-testid="group-filter"]')).to have_content(group.name)
- page.within('.project-filter') do
+ page.within('[data-testid="project-filter"]') do
find('.js-search-project-dropdown').click
wait_for_requests
@@ -44,10 +44,11 @@ RSpec.describe 'User uses search filters', :js do
describe 'clear filter button' do
it 'removes Group and Project filters' do
- link = find('[data-testid="group-filter"] .js-search-clear')
- params = CGI.parse(URI.parse(link[:href]).query)
+ find('[data-testid="group-filter"] [data-testid="clear-icon"]').click
+
+ wait_for_requests
- expect(params).not_to include(:group_id, :project_id)
+ expect(page).to have_current_path(search_path(search: "test"))
end
end
end
@@ -57,7 +58,7 @@ RSpec.describe 'User uses search filters', :js do
it 'shows a project' do
visit search_path
- page.within('.project-filter') do
+ page.within('[data-testid="project-filter"]') do
find('.js-search-project-dropdown').click
wait_for_requests
@@ -77,7 +78,7 @@ RSpec.describe 'User uses search filters', :js do
describe 'clear filter button' do
it 'removes Project filters' do
- link = find('.project-filter .js-search-clear')
+ link = find('[data-testid="project-filter"] .js-search-clear')
params = CGI.parse(URI.parse(link[:href]).query)
expect(params).not_to include(:project_id)
diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb
index 03085917d67..a47579582e2 100644
--- a/spec/features/static_site_editor_spec.rb
+++ b/spec/features/static_site_editor_spec.rb
@@ -73,4 +73,44 @@ RSpec.describe 'Static Site Editor' do
expect(node['data-static-site-generator']).to eq('middleman')
end
end
+
+ describe 'Static Site Editor Content Security Policy' do
+ subject { response_headers['Content-Security-Policy'] }
+
+ context 'when no global CSP config exists' do
+ before do
+ expect_next_instance_of(Projects::StaticSiteEditorController) do |controller|
+ expect(controller).to receive(:current_content_security_policy)
+ .and_return(ActionDispatch::ContentSecurityPolicy.new)
+ end
+ end
+
+ it 'does not add CSP directives' do
+ visit sse_path
+
+ is_expected.to be_blank
+ end
+ end
+
+ context 'when a global CSP config exists' do
+ let_it_be(:cdn_url) { 'https://some-cdn.test' }
+ let_it_be(:youtube_url) { 'https://www.youtube.com' }
+
+ before do
+ csp = ActionDispatch::ContentSecurityPolicy.new do |p|
+ p.frame_src :self, cdn_url
+ end
+
+ expect_next_instance_of(Projects::StaticSiteEditorController) do |controller|
+ expect(controller).to receive(:current_content_security_policy).and_return(csp)
+ end
+ end
+
+ it 'appends youtube to the CSP frame-src policy' do
+ visit sse_path
+
+ is_expected.to eql("frame-src 'self' #{cdn_url} #{youtube_url}")
+ end
+ end
+ end
end
diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb
index 7f55ddc1d64..589cc9f9b02 100644
--- a/spec/features/uploads/user_uploads_file_to_note_spec.rb
+++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb
@@ -58,8 +58,8 @@ RSpec.describe 'User uploads file to note' do
error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.'
expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text)
- expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again')
- expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file')
+ expect(page).to have_button('Try again', visible: true)
+ expect(page).to have_button('attach a new file', visible: true)
expect(page).not_to have_button('Attach a file')
end
end
@@ -78,7 +78,7 @@ RSpec.describe 'User uploads file to note' do
click_button 'Comment'
wait_for_requests
- expect(find('a.no-attachment-icon img[alt="dk"]')['src'])
+ expect(find('a.no-attachment-icon img.js-lazy-loaded[alt="dk"]')['src'])
.to match(%r{/#{project.full_path}/uploads/\h{32}/dk\.png$})
end
end
diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb
index 853c381fe6b..0761c1871d3 100644
--- a/spec/features/users/login_spec.rb
+++ b/spec/features/users/login_spec.rb
@@ -26,7 +26,6 @@ RSpec.describe 'Login' do
user.reload
expect(user.reset_password_token).not_to be_nil
- find('a[href="#login-pane"]').click
gitlab_sign_in(user)
expect(current_path).to eq root_path
@@ -593,42 +592,95 @@ RSpec.describe 'Login' do
describe 'UI tabs and panes' do
context 'when no defaults are changed' do
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness
+ it 'does not render any tabs' do
+ visit new_user_session_path
+
+ ensure_no_tabs
+ end
+
+ it 'renders link to sign up path' do
+ visit new_user_session_path
+
+ expect(page.body).to have_link('Register now', href: new_user_registration_path)
end
end
context 'when signup is disabled' do
before do
stub_application_setting(signup_enabled: false)
+
+ visit new_user_session_path
end
- it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness
+ it 'does not render any tabs' do
+ ensure_no_tabs
+ end
+
+ it 'does not render link to sign up path' do
+ visit new_user_session_path
+
+ expect(page.body).not_to have_link('Register now', href: new_user_registration_path)
end
end
context 'when ldap is enabled' do
+ include LdapHelpers
+
+ let(:provider) { 'ldapmain' }
+ let(:ldap_server_config) do
+ {
+ 'label' => 'Main LDAP',
+ 'provider_name' => provider,
+ 'attributes' => {},
+ 'encryption' => 'plain',
+ 'uid' => 'uid',
+ 'base' => 'dc=example,dc=com'
+ }
+ end
+
before do
+ 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!
+
+ allow_next_instance_of(ActionDispatch::Routing::RoutesProxy) do |instance|
+ allow(instance).to receive(:"user_#{provider}_omniauth_callback_path")
+ .and_return("/users/auth/#{provider}/callback")
+ end
+
visit new_user_session_path
- allow(page).to receive(:form_based_providers).and_return([:ldapmain])
- allow(page).to receive(:ldap_enabled).and_return(true)
end
it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness(false)
+ ensure_tab_pane_correctness(['Main LDAP', 'Standard'])
+ end
+
+ it 'renders link to sign up path' do
+ expect(page.body).to have_link('Register now', href: new_user_registration_path)
end
end
context 'when crowd is enabled' do
before do
+ allow(Gitlab::Auth::OAuth::Provider).to receive_messages(providers: [:crowd])
+ stub_application_setting(crowd_enabled: true)
+
+ Ldap::OmniauthCallbacksController.define_providers!
+ Rails.application.reload_routes!
+
+ allow_next_instance_of(ActionDispatch::Routing::RoutesProxy) do |instance|
+ allow(instance).to receive(:user_crowd_omniauth_authorize_path)
+ .and_return("/users/auth/crowd/callback")
+ end
+
visit new_user_session_path
- allow(page).to receive(:form_based_providers).and_return([:crowd])
- allow(page).to receive(:crowd_enabled?).and_return(true)
end
it 'correctly renders tabs and panes' do
- ensure_tab_pane_correctness(false)
+ ensure_tab_pane_correctness(%w(Crowd Standard))
end
end
end
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index 466b7361da9..aebe2cc602d 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -5,11 +5,13 @@ require 'spec_helper'
RSpec.describe 'User page' do
include ExternalAuthorizationServiceHelpers
- let(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
+ let_it_be(:user) { create(:user, bio: '**Lorem** _ipsum_ dolor sit [amet](https://example.com)') }
+
+ subject { visit(user_path(user)) }
context 'with public profile' do
it 'shows all the tabs' do
- visit(user_path(user))
+ subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
@@ -22,14 +24,12 @@ RSpec.describe 'User page' do
end
it 'does not show private profile message' do
- visit(user_path(user))
+ subject
expect(page).not_to have_content("This user has a private profile")
end
context 'work information' do
- subject { visit(user_path(user)) }
-
it 'shows job title and organization details' do
user.update(organization: 'GitLab - work info test', job_title: 'Frontend Engineer')
@@ -57,24 +57,24 @@ RSpec.describe 'User page' do
end
context 'with private profile' do
- let(:user) { create(:user, private_profile: true) }
+ let_it_be(:user) { create(:user, private_profile: true) }
it 'shows no tab' do
- visit(user_path(user))
+ subject
expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links")
end
it 'shows private profile message' do
- visit(user_path(user))
+ subject
expect(page).to have_content("This user has a private profile")
end
it 'shows own tabs' do
sign_in(user)
- visit(user_path(user))
+ subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
@@ -88,36 +88,36 @@ RSpec.describe 'User page' do
end
context 'with blocked profile' do
- let(:user) { create(:user, state: :blocked) }
+ let_it_be(:user) { create(:user, state: :blocked) }
it 'shows no tab' do
- visit(user_path(user))
+ subject
expect(page).to have_css("div.profile-header")
expect(page).not_to have_css("ul.nav-links")
end
it 'shows blocked message' do
- visit(user_path(user))
+ subject
expect(page).to have_content("This user is blocked")
end
it 'shows user name as blocked' do
- visit(user_path(user))
+ subject
expect(page).to have_css(".cover-title", text: 'Blocked user')
end
it 'shows no additional fields' do
- visit(user_path(user))
+ subject
expect(page).not_to have_css(".profile-user-bio")
expect(page).not_to have_css(".profile-link-holder")
end
it 'shows username' do
- visit(user_path(user))
+ subject
expect(page).to have_content("@#{user.username}")
end
@@ -126,7 +126,7 @@ RSpec.describe 'User page' do
it 'shows the status if there was one' do
create(:user_status, user: user, message: "Working hard!")
- visit(user_path(user))
+ subject
expect(page).to have_content("Working hard!")
end
@@ -135,7 +135,7 @@ RSpec.describe 'User page' do
it 'shows the sign in link' do
stub_application_setting(signup_enabled: false)
- visit(user_path(user))
+ subject
page.within '.navbar-nav' do
expect(page).to have_link('Sign in')
@@ -147,7 +147,7 @@ RSpec.describe 'User page' do
it 'shows the sign in and register link' do
stub_application_setting(signup_enabled: true)
- visit(user_path(user))
+ subject
page.within '.navbar-nav' do
expect(page).to have_link('Sign in / Register')
@@ -157,7 +157,7 @@ RSpec.describe 'User page' do
context 'most recent activity' do
it 'shows the most recent activity' do
- visit(user_path(user))
+ subject
expect(page).to have_content('Most Recent Activity')
end
@@ -168,7 +168,7 @@ RSpec.describe 'User page' do
end
it 'hides the most recent activity' do
- visit(user_path(user))
+ subject
expect(page).not_to have_content('Most Recent Activity')
end
@@ -177,14 +177,14 @@ RSpec.describe 'User page' do
context 'page description' do
before do
- visit(user_path(user))
+ subject
end
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
end
context 'with a bot user' do
- let(:user) { create(:user, user_type: :security_bot) }
+ let_it_be(:user) { create(:user, user_type: :security_bot) }
describe 'feature flag enabled' do
before do
@@ -192,7 +192,7 @@ RSpec.describe 'User page' do
end
it 'only shows Overview and Activity tabs' do
- visit(user_path(user))
+ subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
@@ -211,7 +211,7 @@ RSpec.describe 'User page' do
end
it 'only shows Overview and Activity tabs' do
- visit(user_path(user))
+ subject
page.within '.nav-links' do
expect(page).to have_link('Overview')
@@ -224,4 +224,24 @@ RSpec.describe 'User page' do
end
end
end
+
+ context 'structured markup' do
+ let_it_be(:user) { create(:user, website_url: 'https://gitlab.com', organization: 'GitLab', job_title: 'Frontend Engineer', email: 'public@example.com', public_email: 'public@example.com', location: 'Country', created_at: Time.now, updated_at: Time.now) }
+
+ it 'shows Person structured markup' do
+ subject
+
+ aggregate_failures do
+ expect(page).to have_selector('[itemscope][itemtype="http://schema.org/Person"]')
+ expect(page).to have_selector('img[itemprop="image"]')
+ expect(page).to have_selector('[itemprop="name"]')
+ expect(page).to have_selector('[itemprop="address"][itemscope][itemtype="https://schema.org/PostalAddress"]')
+ expect(page).to have_selector('[itemprop="addressLocality"]')
+ expect(page).to have_selector('[itemprop="url"]')
+ expect(page).to have_selector('[itemprop="email"]')
+ expect(page).to have_selector('span[itemprop="jobTitle"]')
+ expect(page).to have_selector('span[itemprop="worksFor"]')
+ end
+ end
+ end
end
diff --git a/spec/features/users/signup_spec.rb b/spec/features/users/signup_spec.rb
index c59121626f0..bfdd1e1bdb7 100644
--- a/spec/features/users/signup_spec.rb
+++ b/spec/features/users/signup_spec.rb
@@ -2,9 +2,51 @@
require 'spec_helper'
-RSpec.shared_examples 'Signup' do
+RSpec.shared_examples 'Signup name validation' do |field, max_length, label|
+ before do
+ visit new_user_registration_path
+ end
+
+ describe "#{field} validation", :js do
+ it "does not show an error border if the user's fullname length is not longer than #{max_length} characters" do
+ fill_in field, with: 'u' * max_length
+
+ expect(find('.name')).not_to have_css '.gl-field-error-outline'
+ end
+
+ it 'shows an error border if the user\'s fullname contains an emoji' do
+ simulate_input("##{field}", 'Ehsan 🦋')
+
+ expect(find('.name')).to have_css '.gl-field-error-outline'
+ end
+
+ it "shows an error border if the user\'s fullname is longer than #{max_length} characters" do
+ fill_in field, with: 'n' * (max_length + 1)
+
+ expect(find('.name')).to have_css '.gl-field-error-outline'
+ end
+
+ it "shows an error message if the user\'s #{label} is longer than #{max_length} characters" do
+ fill_in field, with: 'n' * (max_length + 1)
+
+ expect(page).to have_content("#{label} is too long (maximum is #{max_length} characters).")
+ end
+
+ it 'shows an error message if the username contains emojis' do
+ simulate_input("##{field}", 'Ehsan 🦋')
+
+ expect(page).to have_content("Invalid input, please avoid emojis")
+ end
+ end
+end
+
+RSpec.describe 'Signup' do
include TermsHelper
+ before do
+ stub_application_setting(require_admin_approval_after_user_signup: false)
+ end
+
let(:new_user) { build_stubbed(:user) }
def fill_in_signup_form
@@ -190,6 +232,22 @@ RSpec.shared_examples 'Signup' do
expect(current_path).to eq users_sign_up_welcome_path
end
end
+
+ context 'with required admin approval enabled' do
+ before do
+ stub_application_setting(require_admin_approval_after_user_signup: true)
+ end
+
+ it 'creates the user but does not sign them in' do
+ visit new_user_registration_path
+
+ fill_in_signup_form
+
+ expect { click_button 'Register' }.to change { User.count }.by(1)
+ expect(current_path).to eq new_user_session_path
+ expect(page).to have_content("You have signed up successfully. However, we could not sign you in because your account is awaiting approval from your GitLab administrator")
+ end
+ end
end
context 'with errors' do
@@ -295,64 +353,7 @@ RSpec.shared_examples 'Signup' do
expect(created_user.setup_for_company).to be_nil
expect(page).to have_current_path(new_project_path)
end
-end
-
-RSpec.shared_examples 'Signup name validation' do |field, max_length, label|
- before do
- visit new_user_registration_path
- end
-
- describe "#{field} validation", :js do
- it "does not show an error border if the user's fullname length is not longer than #{max_length} characters" do
- fill_in field, with: 'u' * max_length
-
- expect(find('.name')).not_to have_css '.gl-field-error-outline'
- end
-
- it 'shows an error border if the user\'s fullname contains an emoji' do
- simulate_input("##{field}", 'Ehsan 🦋')
-
- expect(find('.name')).to have_css '.gl-field-error-outline'
- end
-
- it "shows an error border if the user\'s fullname is longer than #{max_length} characters" do
- fill_in field, with: 'n' * (max_length + 1)
-
- expect(find('.name')).to have_css '.gl-field-error-outline'
- end
-
- it "shows an error message if the user\'s #{label} is longer than #{max_length} characters" do
- fill_in field, with: 'n' * (max_length + 1)
-
- expect(page).to have_content("#{label} is too long (maximum is #{max_length} characters).")
- end
-
- it 'shows an error message if the username contains emojis' do
- simulate_input("##{field}", 'Ehsan 🦋')
-
- expect(page).to have_content("Invalid input, please avoid emojis")
- end
- end
-end
-
-RSpec.describe 'With original flow' do
- before do
- stub_experiment(signup_flow: false)
- stub_experiment_for_user(signup_flow: false)
- end
-
- it_behaves_like 'Signup'
- it_behaves_like 'Signup name validation', 'new_user_first_name', 127, 'First name'
- it_behaves_like 'Signup name validation', 'new_user_last_name', 127, 'Last name'
-end
-
-RSpec.describe 'With experimental flow' do
- before do
- stub_experiment(signup_flow: true)
- stub_experiment_for_user(signup_flow: true)
- end
- it_behaves_like 'Signup'
it_behaves_like 'Signup name validation', 'new_user_first_name', 127, 'First name'
it_behaves_like 'Signup name validation', 'new_user_last_name', 127, 'Last name'
end
diff --git a/spec/finders/alert_management/http_integrations_finder_spec.rb b/spec/finders/alert_management/http_integrations_finder_spec.rb
new file mode 100644
index 00000000000..d65de2cdbbd
--- /dev/null
+++ b/spec/finders/alert_management/http_integrations_finder_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegrationsFinder do
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project ) }
+ let_it_be(:extra_integration) { create(:alert_management_http_integration, project: project ) }
+ let_it_be(:alt_project_integration) { create(:alert_management_http_integration) }
+
+ let(:params) { {} }
+
+ describe '#execute' do
+ subject(:execute) { described_class.new(project, params).execute }
+
+ context 'empty params' do
+ it { is_expected.to contain_exactly(integration) }
+ end
+
+ context 'endpoint_identifier param given' do
+ let(:params) { { endpoint_identifier: integration.endpoint_identifier } }
+
+ it { is_expected.to contain_exactly(integration) }
+
+ context 'matches an unavailable integration' do
+ let(:params) { { endpoint_identifier: extra_integration.endpoint_identifier } }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'but unknown' do
+ let(:params) { { endpoint_identifier: 'unknown' } }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'but blank' do
+ let(:params) { { endpoint_identifier: nil } }
+
+ it { is_expected.to contain_exactly(integration) }
+ end
+ end
+
+ context 'active param given' do
+ let(:params) { { active: true } }
+
+ it { is_expected.to contain_exactly(integration) }
+
+ context 'when integration is disabled' do
+ before do
+ integration.update!(active: false)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'but blank' do
+ let(:params) { { active: nil } }
+
+ it { is_expected.to contain_exactly(integration) }
+ end
+ end
+
+ context 'project has no integrations' do
+ subject(:execute) { described_class.new(create(:project), params).execute }
+
+ it { is_expected.to be_empty }
+ end
+ end
+end
diff --git a/spec/finders/ci/commit_statuses_finder_spec.rb b/spec/finders/ci/commit_statuses_finder_spec.rb
new file mode 100644
index 00000000000..1aa9cb12432
--- /dev/null
+++ b/spec/finders/ci/commit_statuses_finder_spec.rb
@@ -0,0 +1,178 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::CommitStatusesFinder, '#execute' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:release) { create(:release, project: project) }
+ let_it_be(:user) { create(:user) }
+
+ context 'tag refs' do
+ let_it_be(:tags) { TagsFinder.new(project.repository, {}).execute }
+ let(:subject) { described_class.new(project, project.repository, user, tags).execute }
+
+ context 'no pipelines' do
+ it 'returns nil' do
+ expect(subject).to be_blank
+ end
+ end
+
+ context 'when multiple tags exist' do
+ before do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.1.0',
+ sha: project.commit('v1.1.0').sha,
+ status: :running)
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :success)
+ end
+
+ it 'all relevant commit statuses are received' do
+ expect(subject['v1.1.0'].group).to eq("running")
+ expect(subject['v1.0.0'].group).to eq("success")
+ end
+ end
+
+ context 'when a tag has multiple pipelines' do
+ before do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :running,
+ created_at: 6.months.ago)
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.0.0',
+ sha: project.commit('v1.0.0').sha,
+ status: :success,
+ created_at: 2.months.ago)
+ end
+
+ it 'chooses the latest to determine status' do
+ expect(subject['v1.0.0'].group).to eq("success")
+ end
+ end
+ end
+
+ context 'branch refs' do
+ let(:subject) { described_class.new(project, project.repository, user, branches).execute }
+
+ before do
+ project.add_developer(user)
+ end
+
+ context 'no pipelines' do
+ let(:branches) { BranchesFinder.new(project.repository, {}).execute }
+
+ it 'returns nil' do
+ expect(subject).to be_blank
+ end
+ end
+
+ context 'when a branch has multiple pipelines' do
+ let(:branches) { BranchesFinder.new(project.repository, {}).execute }
+
+ before do
+ sha = project.repository.create_file(user, generate(:branch), 'content', message: 'message', branch_name: 'master')
+ create(:ci_pipeline,
+ project: project,
+ user: user,
+ ref: "master",
+ sha: sha,
+ status: :running,
+ created_at: 6.months.ago)
+ create(:ci_pipeline,
+ project: project,
+ user: user,
+ ref: "master",
+ sha: sha,
+ status: :success,
+ created_at: 2.months.ago)
+ end
+
+ it 'chooses the latest to determine status' do
+ expect(subject["master"].group).to eq("success")
+ end
+ end
+
+ context 'when multiple branches exist' do
+ let(:branches) { BranchesFinder.new(project.repository, {}).execute }
+
+ before do
+ master_sha = project.repository.create_file(user, generate(:branch), 'content', message: 'message', branch_name: 'master')
+ create(:ci_pipeline,
+ project: project,
+ user: user,
+ ref: "master",
+ sha: master_sha,
+ status: :running,
+ created_at: 6.months.ago)
+ test_sha = project.repository.create_file(user, generate(:branch), 'content', message: 'message', branch_name: 'test')
+ create(:ci_pipeline,
+ project: project,
+ user: user,
+ ref: "test",
+ sha: test_sha,
+ status: :success,
+ created_at: 2.months.ago)
+ end
+
+ it 'all relevant commit statuses are received' do
+ expect(subject["master"].group).to eq("running")
+ expect(subject["test"].group).to eq("success")
+ end
+ end
+ end
+
+ context 'CI pipelines visible to' do
+ let_it_be(:tags) { TagsFinder.new(project.repository, {}).execute }
+ let(:subject) { described_class.new(project, project.repository, user, tags).execute }
+
+ before do
+ create(:ci_pipeline,
+ project: project,
+ ref: 'v1.1.0',
+ sha: project.commit('v1.1.0').sha,
+ status: :running)
+ end
+
+ context 'everyone' do
+ it 'returns something' do
+ expect(subject).not_to be_blank
+ end
+ end
+
+ context 'project members only' do
+ before do
+ project.project_feature.update!(builds_access_level: ProjectFeature::PRIVATE)
+ end
+
+ it 'returns nil' do
+ expect(subject).to be_empty
+ end
+ end
+
+ context 'when not a member of a private project' do
+ let(:private_project) { create(:project, :private, :repository) }
+ let(:private_tags) { TagsFinder.new(private_tags.repository, {}).execute }
+ let(:private_subject) { described_class.new(private_project, private_project.repository, user, tags).execute }
+
+ before do
+ create(:ci_pipeline,
+ project: private_project,
+ ref: 'v1.1.0',
+ sha: private_project.commit('v1.1.0').sha,
+ status: :running)
+ end
+
+ it 'returns nil' do
+ expect(private_subject).to be_empty
+ end
+ end
+ end
+end
diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb
index a6a41c36489..4a6585e3f2b 100644
--- a/spec/finders/ci/jobs_finder_spec.rb
+++ b/spec/finders/ci/jobs_finder_spec.rb
@@ -36,135 +36,62 @@ RSpec.describe Ci::JobsFinder, '#execute' do
end
end
- context 'with ci_jobs_finder_refactor ff enabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: true)
- end
-
- context 'scope is present' do
- let(:jobs) { [job_1, job_2, job_3] }
-
- where(:scope, :index) do
- [
- ['pending', 0],
- ['running', 1],
- ['finished', 2]
- ]
- end
-
- with_them do
- let(:params) { { scope: scope } }
-
- it { expect(subject).to match_array([jobs[index]]) }
- end
+ context 'scope is present' do
+ let(:jobs) { [job_1, job_2, job_3] }
+
+ where(:scope, :index) do
+ [
+ ['pending', 0],
+ ['running', 1],
+ ['finished', 2]
+ ]
end
- context 'scope is an array' do
- let(:jobs) { [job_1, job_2, job_3] }
- let(:params) {{ scope: ['running'] }}
+ with_them do
+ let(:params) { { scope: scope } }
- it 'filters by the job statuses in the scope' do
- expect(subject).to match_array([job_2])
- end
+ it { expect(subject).to match_array([jobs[index]]) }
end
end
- context 'with ci_jobs_finder_refactor ff disabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: false)
- end
-
- context 'scope is present' do
- let(:jobs) { [job_1, job_2, job_3] }
-
- where(:scope, :index) do
- [
- ['pending', 0],
- ['running', 1],
- ['finished', 2]
- ]
- end
+ context 'scope is an array' do
+ let(:jobs) { [job_1, job_2, job_3] }
+ let(:params) {{ scope: ['running'] }}
- with_them do
- let(:params) { { scope: scope } }
-
- it { expect(subject).to match_array([jobs[index]]) }
- end
+ it 'filters by the job statuses in the scope' do
+ expect(subject).to match_array([job_2])
end
end
end
- context 'with ci_jobs_finder_refactor ff enabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: true)
- end
-
- context 'a project is present' do
- subject { described_class.new(current_user: user, project: project, params: params).execute }
-
- context 'user has access to the project' do
- before do
- project.add_maintainer(user)
- end
-
- it 'returns jobs for the specified project' do
- expect(subject).to match_array([job_3])
- end
- end
+ context 'a project is present' do
+ subject { described_class.new(current_user: user, project: project, params: params).execute }
- context 'user has no access to project builds' do
- before do
- project.add_guest(user)
- end
-
- it 'returns no jobs' do
- expect(subject).to be_empty
- end
+ context 'user has access to the project' do
+ before do
+ project.add_maintainer(user)
end
- context 'without user' do
- let(:user) { nil }
-
- it 'returns no jobs' do
- expect(subject).to be_empty
- end
+ it 'returns jobs for the specified project' do
+ expect(subject).to match_array([job_3])
end
end
- end
-
- context 'with ci_jobs_finder_refactor ff disabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: false)
- end
- context 'a project is present' do
- subject { described_class.new(current_user: user, project: project, params: params).execute }
- context 'user has access to the project' do
- before do
- project.add_maintainer(user)
- end
-
- it 'returns jobs for the specified project' do
- expect(subject).to match_array([job_3])
- end
+ context 'user has no access to project builds' do
+ before do
+ project.add_guest(user)
end
- context 'user has no access to project builds' do
- before do
- project.add_guest(user)
- end
-
- it 'returns no jobs' do
- expect(subject).to be_empty
- end
+ it 'returns no jobs' do
+ expect(subject).to be_empty
end
+ end
- context 'without user' do
- let(:user) { nil }
+ context 'without user' do
+ let(:user) { nil }
- it 'returns no jobs' do
- expect(subject).to be_empty
- end
+ it 'returns no jobs' do
+ expect(subject).to be_empty
end
end
end
diff --git a/spec/finders/environment_names_finder_spec.rb b/spec/finders/environment_names_finder_spec.rb
index 9244e4fb369..fe00c800f0a 100644
--- a/spec/finders/environment_names_finder_spec.rb
+++ b/spec/finders/environment_names_finder_spec.rb
@@ -5,58 +5,178 @@ require 'spec_helper'
RSpec.describe EnvironmentNamesFinder do
describe '#execute' do
let!(:group) { create(:group) }
- let!(:project1) { create(:project, :public, namespace: group) }
- let!(:project2) { create(:project, :private, namespace: group) }
+ let!(:public_project) { create(:project, :public, namespace: group) }
+ let!(:private_project) { create(:project, :private, namespace: group) }
let!(:user) { create(:user) }
before do
- create(:environment, name: 'gstg', project: project1)
- create(:environment, name: 'gprd', project: project1)
- create(:environment, name: 'gprd', project: project2)
- create(:environment, name: 'gcny', project: project2)
+ create(:environment, name: 'gstg', project: public_project)
+ create(:environment, name: 'gprd', project: public_project)
+ create(:environment, name: 'gprd', project: private_project)
+ create(:environment, name: 'gcny', project: private_project)
end
- context 'using a group and a group member' do
- it 'returns environment names for all projects' do
- group.add_developer(user)
+ context 'using a group' do
+ context 'with a group developer' do
+ it 'returns environment names for all projects' do
+ group.add_developer(user)
- names = described_class.new(group, user).execute
+ names = described_class.new(group, user).execute
- expect(names).to eq(%w[gcny gprd gstg])
+ expect(names).to eq(%w[gcny gprd gstg])
+ end
end
- end
- context 'using a group and a guest' do
- it 'returns environment names for all public projects' do
- names = described_class.new(group, user).execute
+ context 'with a group reporter' do
+ it 'returns environment names for all projects' do
+ group.add_reporter(user)
+
+ names = described_class.new(group, user).execute
- expect(names).to eq(%w[gprd gstg])
+ expect(names).to eq(%w[gcny gprd gstg])
+ end
end
- end
- context 'using a public project and a project member' do
- it 'returns all the unique environment names' do
- project1.team.add_developer(user)
+ context 'with a public project reporter' do
+ it 'returns environment names for all public projects' do
+ public_project.add_reporter(user)
+
+ names = described_class.new(group, user).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'with a private project reporter' do
+ it 'returns environment names for all public projects' do
+ private_project.add_reporter(user)
+
+ names = described_class.new(group, user).execute
+
+ expect(names).to eq(%w[gcny gprd gstg])
+ end
+ end
+
+ context 'with a group guest' do
+ it 'returns environment names for all public projects' do
+ group.add_guest(user)
+
+ names = described_class.new(group, user).execute
- names = described_class.new(project1, user).execute
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'with a non-member' do
+ it 'returns environment names for all public projects' do
+ names = described_class.new(group, user).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'without a user' do
+ it 'returns environment names for all public projects' do
+ names = described_class.new(group).execute
- expect(names).to eq(%w[gprd gstg])
+ expect(names).to eq(%w[gprd gstg])
+ end
end
end
- context 'using a public project and a guest' do
- it 'returns all the unique environment names' do
- names = described_class.new(project1, user).execute
+ context 'using a public project' do
+ context 'with a project developer' do
+ it 'returns all the unique environment names' do
+ public_project.add_developer(user)
+
+ names = described_class.new(public_project, user).execute
- expect(names).to eq(%w[gprd gstg])
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'with a project reporter' do
+ it 'returns all the unique environment names' do
+ public_project.add_reporter(user)
+
+ names = described_class.new(public_project, user).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'with a project guest' do
+ it 'returns all the unique environment names' do
+ public_project.add_guest(user)
+
+ names = described_class.new(public_project, user).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'with a non-member' do
+ it 'returns all the unique environment names' do
+ names = described_class.new(public_project, user).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
+ end
+
+ context 'without a user' do
+ it 'returns all the unique environment names' do
+ names = described_class.new(public_project).execute
+
+ expect(names).to eq(%w[gprd gstg])
+ end
end
end
- context 'using a private project and a guest' do
- it 'returns all the unique environment names' do
- names = described_class.new(project2, user).execute
+ context 'using a private project' do
+ context 'with a project developer' do
+ it 'returns all the unique environment names' do
+ private_project.add_developer(user)
+
+ names = described_class.new(private_project, user).execute
+
+ expect(names).to eq(%w[gcny gprd])
+ end
+ end
+
+ context 'with a project reporter' do
+ it 'returns all the unique environment names' do
+ private_project.add_reporter(user)
+
+ names = described_class.new(private_project, user).execute
+
+ expect(names).to eq(%w[gcny gprd])
+ end
+ end
+
+ context 'with a project guest' do
+ it 'does not return any environment names' do
+ private_project.add_guest(user)
+
+ names = described_class.new(private_project, user).execute
+
+ expect(names).to be_empty
+ end
+ end
+
+ context 'with a non-member' do
+ it 'does not return any environment names' do
+ names = described_class.new(private_project, user).execute
+
+ expect(names).to be_empty
+ end
+ end
+
+ context 'without a user' do
+ it 'does not return any environment names' do
+ names = described_class.new(private_project).execute
- expect(names).to be_empty
+ expect(names).to be_empty
+ end
end
end
end
diff --git a/spec/finders/feature_flags_user_lists_finder_spec.rb b/spec/finders/feature_flags_user_lists_finder_spec.rb
new file mode 100644
index 00000000000..fc904a174d7
--- /dev/null
+++ b/spec/finders/feature_flags_user_lists_finder_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe FeatureFlagsUserListsFinder do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ before_all do
+ project.add_maintainer(user)
+ end
+
+ describe '#execute' do
+ it 'returns user lists' do
+ finder = described_class.new(project, user, {})
+ user_list = create(:operations_feature_flag_user_list, project: project)
+
+ expect(finder.execute).to contain_exactly(user_list)
+ end
+
+ context 'with search' do
+ it 'returns only matching user lists' do
+ create(:operations_feature_flag_user_list, name: 'do not find', project: project)
+ user_list = create(:operations_feature_flag_user_list, name: 'testing', project: project)
+ finder = described_class.new(project, user, { search: "test" })
+
+ expect(finder.execute).to contain_exactly(user_list)
+ end
+ end
+ end
+end
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
index 2f9303606b1..b66d0ffce87 100644
--- a/spec/finders/group_descendants_finder_spec.rb
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe GroupDescendantsFinder do
- let(:user) { create(:user) }
- let(:group) { create(:group) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
let(:params) { {} }
subject(:finder) do
@@ -129,6 +129,39 @@ RSpec.describe GroupDescendantsFinder do
end
end
+ context 'with shared groups' do
+ let_it_be(:other_group) { create(:group) }
+ let_it_be(:shared_group_link) do
+ create(:group_group_link,
+ shared_group: group,
+ shared_with_group: other_group)
+ end
+
+ context 'without common ancestor' do
+ it { expect(finder.execute).to be_empty }
+ end
+
+ context 'with common ancestor' do
+ let_it_be(:common_ancestor) { create(:group) }
+ let_it_be(:other_group) { create(:group, parent: common_ancestor) }
+ let_it_be(:group) { create(:group, parent: common_ancestor) }
+
+ context 'querying under the common ancestor' do
+ it { expect(finder.execute).to be_empty }
+ end
+
+ context 'querying the common ancestor' do
+ subject(:finder) do
+ described_class.new(current_user: user, parent_group: common_ancestor, params: params)
+ end
+
+ it 'contains shared subgroups' do
+ expect(finder.execute).to contain_exactly(group, other_group)
+ end
+ end
+ end
+ end
+
context 'with nested groups' do
let!(:project) { create(:project, namespace: group) }
let!(:subgroup) { create(:group, :private, parent: group) }
diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb
index 21bc03011c3..3c3bf1a8870 100644
--- a/spec/finders/issues_finder_spec.rb
+++ b/spec/finders/issues_finder_spec.rb
@@ -472,6 +472,10 @@ RSpec.describe IssuesFinder do
it 'returns issues with title and description match for search term' do
expect(issues).to contain_exactly(issue1, issue2)
end
+
+ it 'uses optimizer hints' do
+ expect(issues.to_sql).to match(/BitmapScan/)
+ end
end
context 'filtering by issue term in title' do
@@ -827,6 +831,46 @@ RSpec.describe IssuesFinder do
let(:project_params) { { project_id: project.id } }
end
end
+
+ context 'filtering by due date' do
+ let_it_be(:issue_overdue) { create(:issue, project: project1, due_date: 2.days.ago) }
+ let_it_be(:issue_due_soon) { create(:issue, project: project1, due_date: 2.days.from_now) }
+
+ let(:scope) { 'all' }
+ let(:base_params) { { project_id: project1.id } }
+
+ context 'with param set to no due date' do
+ let(:params) { base_params.merge(due_date: Issue::NoDueDate.name) }
+
+ it 'returns issues with no due date' do
+ expect(issues).to contain_exactly(issue1)
+ end
+ end
+
+ context 'with param set to overdue' do
+ let(:params) { base_params.merge(due_date: Issue::Overdue.name) }
+
+ it 'returns overdue issues' do
+ expect(issues).to contain_exactly(issue_overdue)
+ end
+ end
+
+ context 'with param set to next month and previous two weeks' do
+ let(:params) { base_params.merge(due_date: Issue::DueNextMonthAndPreviousTwoWeeks.name) }
+
+ it 'returns issues from the previous two weeks and next month' do
+ expect(issues).to contain_exactly(issue_overdue, issue_due_soon)
+ end
+ end
+
+ context 'with invalid param' do
+ let(:params) { base_params.merge(due_date: 'foo') }
+
+ it 'returns no issues' do
+ expect(issues).to be_empty
+ end
+ end
+ end
end
describe '#row_count', :request_store do
diff --git a/spec/finders/merge_requests/by_approvals_finder_spec.rb b/spec/finders/merge_requests/by_approvals_finder_spec.rb
index 0e1856879f1..5c56e610c0b 100644
--- a/spec/finders/merge_requests/by_approvals_finder_spec.rb
+++ b/spec/finders/merge_requests/by_approvals_finder_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe MergeRequests::ByApprovalsFinder do
create(:approval, merge_request: mr, user: first_user)
end
end
+
let_it_be(:merge_request_with_both_approvals) do
create(:merge_request).tap do |mr|
create(:approval, merge_request: mr, user: first_user)
diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb
index 163c920f621..0db69de65a5 100644
--- a/spec/finders/packages/group_packages_finder_spec.rb
+++ b/spec/finders/packages/group_packages_finder_spec.rb
@@ -2,13 +2,16 @@
require 'spec_helper'
RSpec.describe Packages::GroupPackagesFinder do
- let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group) }
- let_it_be(:project) { create(:project, namespace: group) }
- let(:another_group) { create(:group) }
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be(:user) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, namespace: group, builds_access_level: ProjectFeature::PRIVATE, merge_requests_access_level: ProjectFeature::PRIVATE) }
+
+ let(:add_user_to_group) { true }
before do
- group.add_developer(user)
+ group.add_developer(user) if add_user_to_group
end
describe '#execute' do
@@ -27,16 +30,16 @@ RSpec.describe Packages::GroupPackagesFinder do
end
context 'group has packages' do
- let!(:package1) { create(:maven_package, project: project) }
- let!(:package2) { create(:maven_package, project: project) }
- let!(:package3) { create(:maven_package) }
+ let_it_be(:package1) { create(:maven_package, project: project) }
+ let_it_be(:package2) { create(:maven_package, project: project) }
+ let_it_be(:package3) { create(:maven_package) }
it { is_expected.to match_array([package1, package2]) }
context 'subgroup has packages' do
- let(:subgroup) { create(:group, parent: group) }
- let(:subproject) { create(:project, namespace: subgroup) }
- let!(:package4) { create(:npm_package, project: subproject) }
+ let_it_be_with_reload(:subgroup) { create(:group, parent: group) }
+ let_it_be_with_reload(:subproject) { create(:project, namespace: subgroup, builds_access_level: ProjectFeature::PRIVATE, merge_requests_access_level: ProjectFeature::PRIVATE) }
+ let_it_be(:package4) { create(:npm_package, project: subproject) }
it { is_expected.to match_array([package1, package2, package4]) }
@@ -45,16 +48,87 @@ RSpec.describe Packages::GroupPackagesFinder do
it { is_expected.to match_array([package1, package2]) }
end
+
+ context 'permissions' do
+ let(:add_user_to_group) { false }
+
+ where(:role, :project_visibility, :repository_visibility, :packages_returned) do
+ :anonymous | :public | :enabled | :all
+ :guest | :public | :enabled | :all
+ :reporter | :public | :enabled | :all
+ :developer | :public | :enabled | :all
+ :maintainer | :public | :enabled | :all
+ :anonymous | :public | :private | :none
+ :guest | :public | :private | :all
+ :reporter | :public | :private | :all
+ :developer | :public | :private | :all
+ :maintainer | :public | :private | :all
+ :anonymous | :private | :enabled | :none
+ :guest | :private | :enabled | :none
+ :reporter | :private | :enabled | :all
+ :developer | :private | :enabled | :all
+ :maintainer | :private | :enabled | :all
+ :anonymous | :private | :private | :none
+ :guest | :private | :private | :none
+ :reporter | :private | :private | :all
+ :developer | :private | :private | :all
+ :maintainer | :private | :private | :all
+ end
+
+ with_them do
+ let(:expected_packages) do
+ case packages_returned
+ when :all
+ [package1, package2, package4]
+ when :none
+ []
+ end
+ end
+
+ before do
+ subgroup.update!(visibility: project_visibility.to_s)
+ group.update!(visibility: project_visibility.to_s)
+ project.update!(
+ visibility: project_visibility.to_s,
+ repository_access_level: repository_visibility.to_s
+ )
+ subproject.update!(
+ visibility: project_visibility.to_s,
+ repository_access_level: repository_visibility.to_s
+ )
+
+ unless role == :anonymous
+ project.add_user(user, role)
+ subproject.add_user(user, role)
+ end
+ end
+
+ it { is_expected.to match_array(expected_packages) }
+ end
+ end
+
+ context 'avoid N+1 query' do
+ it 'avoids N+1 database queries' do
+ count = ActiveRecord::QueryRecorder.new { subject }
+ .count
+
+ Packages::Package.package_types.keys.each do |package_type|
+ create("#{package_type}_package", project: create(:project, namespace: subgroup))
+ end
+
+ expect { described_class.new(user, group, params).execute }.not_to exceed_query_limit(count)
+ end
+ end
end
context 'when there are processing packages' do
- let!(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
+ let_it_be(:package4) { create(:nuget_package, project: project, name: Packages::Nuget::CreatePackageService::TEMPORARY_PACKAGE_NAME) }
it { is_expected.to match_array([package1, package2]) }
end
context 'does not include packages without version number' do
- let!(:package_without_version) { create(:maven_package, project: project, version: nil) }
+ let_it_be(:package_without_version) { create(:maven_package, project: project, version: nil) }
it { is_expected.not_to include(package_without_version) }
end
@@ -80,7 +154,7 @@ RSpec.describe Packages::GroupPackagesFinder do
end
context 'group has package of all types' do
- package_types.each { |pt| let!("package_#{pt}") { create("#{pt}_package", project: project) } }
+ package_types.each { |pt| let_it_be("package_#{pt}") { create("#{pt}_package", project: project) } }
package_types.each do |package_type|
it_behaves_like 'with package type', package_type
@@ -98,7 +172,7 @@ RSpec.describe Packages::GroupPackagesFinder do
end
context 'package type is nil' do
- let!(:package1) { create(:maven_package, project: project) }
+ let_it_be(:package1) { create(:maven_package, project: project) }
subject { described_class.new(user, group, package_type: nil).execute }
@@ -110,47 +184,5 @@ RSpec.describe Packages::GroupPackagesFinder do
it { expect { subject }.to raise_exception(described_class::InvalidPackageTypeError) }
end
-
- context 'when project is public' do
- let_it_be(:other_user) { create(:user) }
- let(:finder) { described_class.new(other_user, group) }
-
- before do
- project.update!(visibility_level: ProjectFeature::ENABLED)
- end
-
- context 'when packages are public' do
- before do
- project.project_feature.update!(
- builds_access_level: ProjectFeature::PRIVATE,
- merge_requests_access_level: ProjectFeature::PRIVATE,
- repository_access_level: ProjectFeature::ENABLED)
- end
-
- it 'returns group packages' do
- package1 = create(:maven_package, project: project)
- package2 = create(:maven_package, project: project)
- create(:maven_package)
-
- expect(finder.execute).to match_array([package1, package2])
- end
- end
-
- context 'packages are members only' do
- before do
- project.project_feature.update!(
- builds_access_level: ProjectFeature::PRIVATE,
- merge_requests_access_level: ProjectFeature::PRIVATE,
- repository_access_level: ProjectFeature::PRIVATE)
-
- create(:maven_package, project: project)
- create(:maven_package)
- end
-
- it 'filters out the project if the user doesn\'t have permission' do
- expect(finder.execute).to be_empty
- end
- end
- end
end
end
diff --git a/spec/finders/packages/npm/package_finder_spec.rb b/spec/finders/packages/npm/package_finder_spec.rb
index be54b1f8b18..78c23971f92 100644
--- a/spec/finders/packages/npm/package_finder_spec.rb
+++ b/spec/finders/packages/npm/package_finder_spec.rb
@@ -16,6 +16,12 @@ RSpec.describe ::Packages::Npm::PackageFinder do
it { is_expected.to be_empty }
end
+
+ context 'with nil project' do
+ let(:project) { nil }
+
+ it { is_expected.to be_empty }
+ end
end
describe '#find_by_version' do
diff --git a/spec/finders/personal_access_tokens_finder_spec.rb b/spec/finders/personal_access_tokens_finder_spec.rb
index c8913329839..cece80047e1 100644
--- a/spec/finders/personal_access_tokens_finder_spec.rb
+++ b/spec/finders/personal_access_tokens_finder_spec.rb
@@ -62,6 +62,27 @@ RSpec.describe PersonalAccessTokensFinder do
revoked_impersonation_token, expired_impersonation_token)
end
+ describe 'with users' do
+ let(:user2) { create(:user) }
+
+ before do
+ create(:personal_access_token, user: user2)
+ create(:personal_access_token, :expired, user: user2)
+ create(:personal_access_token, :revoked, user: user2)
+ create(:personal_access_token, :impersonation, user: user2)
+ create(:personal_access_token, :expired, :impersonation, user: user2)
+ create(:personal_access_token, :revoked, :impersonation, user: user2)
+
+ params[:users] = [user]
+ end
+
+ it {
+ is_expected.to contain_exactly(active_personal_access_token, active_impersonation_token,
+ revoked_personal_access_token, expired_personal_access_token,
+ revoked_impersonation_token, expired_impersonation_token)
+ }
+ end
+
describe 'with sort order' do
before do
params[:sort] = 'id_asc'
diff --git a/spec/finders/security/jobs_finder_spec.rb b/spec/finders/security/jobs_finder_spec.rb
new file mode 100644
index 00000000000..9badf9c38cf
--- /dev/null
+++ b/spec/finders/security/jobs_finder_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Security::JobsFinder do
+ it 'is an abstract class that does not permit instantiation' do
+ expect { described_class.new(pipeline: nil) }.to raise_error(
+ NotImplementedError,
+ 'This is an abstract class, please instantiate its descendants'
+ )
+ end
+
+ describe '.allowed_job_types' do
+ it 'must be implemented by child classes' do
+ expect { described_class.allowed_job_types }.to raise_error(
+ NotImplementedError,
+ 'allowed_job_types must be overwritten to return an array of job types'
+ )
+ end
+ end
+end
diff --git a/spec/finders/security/license_compliance_jobs_finder_spec.rb b/spec/finders/security/license_compliance_jobs_finder_spec.rb
new file mode 100644
index 00000000000..3066912df12
--- /dev/null
+++ b/spec/finders/security/license_compliance_jobs_finder_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Security::LicenseComplianceJobsFinder do
+ it_behaves_like ::Security::JobsFinder, described_class.allowed_job_types
+
+ describe "#execute" do
+ subject { finder.execute }
+
+ let(:pipeline) { create(:ci_pipeline) }
+ let(:finder) { described_class.new(pipeline: pipeline) }
+
+ let!(:sast_build) { create(:ci_build, :sast, pipeline: pipeline) }
+ let!(:container_scanning_build) { create(:ci_build, :container_scanning, pipeline: pipeline) }
+ let!(:dast_build) { create(:ci_build, :dast, pipeline: pipeline) }
+ let!(:license_scanning_build) { create(:ci_build, :license_scanning, pipeline: pipeline) }
+ let!(:license_management_build) { create(:ci_build, :license_management, pipeline: pipeline) }
+
+ it 'returns only the license_scanning jobs' do
+ is_expected.to contain_exactly(license_scanning_build, license_management_build)
+ end
+ end
+end
diff --git a/spec/finders/security/security_jobs_finder_spec.rb b/spec/finders/security/security_jobs_finder_spec.rb
new file mode 100644
index 00000000000..fa8253b96b5
--- /dev/null
+++ b/spec/finders/security/security_jobs_finder_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Security::SecurityJobsFinder do
+ it_behaves_like ::Security::JobsFinder, described_class.allowed_job_types
+
+ describe "#execute" do
+ let(:pipeline) { create(:ci_pipeline) }
+ let(:finder) { described_class.new(pipeline: pipeline) }
+
+ subject { finder.execute }
+
+ context 'with specific secure job types' do
+ let!(:sast_build) { create(:ci_build, :sast, pipeline: pipeline) }
+ let!(:container_scanning_build) { create(:ci_build, :container_scanning, pipeline: pipeline) }
+ let!(:dast_build) { create(:ci_build, :dast, pipeline: pipeline) }
+ let!(:secret_detection_build) { create(:ci_build, :secret_detection, pipeline: pipeline) }
+
+ let(:finder) { described_class.new(pipeline: pipeline, job_types: [:sast, :container_scanning, :secret_detection]) }
+
+ it 'returns only those requested' do
+ is_expected.to include(sast_build)
+ is_expected.to include(container_scanning_build)
+ is_expected.to include(secret_detection_build)
+
+ is_expected.not_to include(dast_build)
+ end
+ end
+
+ context 'with combination of security jobs and license management jobs' do
+ let!(:sast_build) { create(:ci_build, :sast, pipeline: pipeline) }
+ let!(:container_scanning_build) { create(:ci_build, :container_scanning, pipeline: pipeline) }
+ let!(:dast_build) { create(:ci_build, :dast, pipeline: pipeline) }
+ let!(:secret_detection_build) { create(:ci_build, :secret_detection, pipeline: pipeline) }
+ let!(:license_management_build) { create(:ci_build, :license_management, pipeline: pipeline) }
+
+ it 'returns only the security jobs' do
+ is_expected.to include(sast_build)
+ is_expected.to include(container_scanning_build)
+ is_expected.to include(dast_build)
+ is_expected.to include(secret_detection_build)
+ is_expected.not_to include(license_management_build)
+ end
+ end
+ end
+end
diff --git a/spec/finders/user_groups_counter_spec.rb b/spec/finders/user_groups_counter_spec.rb
new file mode 100644
index 00000000000..49587e6dada
--- /dev/null
+++ b/spec/finders/user_groups_counter_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe UserGroupsCounter do
+ subject { described_class.new(user_ids).execute }
+
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group_member1) { create(:group_member, source: group1, user_id: user.id, access_level: Gitlab::Access::OWNER) }
+ let_it_be(:user_ids) { [user.id] }
+
+ it 'returns authorized group count for the user' do
+ expect(subject[user.id]).to eq(1)
+ end
+
+ context 'when request to join group is pending' do
+ let_it_be(:pending_group) { create(:group) }
+ let_it_be(:pending_group_member) { create(:group_member, requested_at: Time.current.utc, source: pending_group, user_id: user.id) }
+
+ it 'does not include pending group in the count' do
+ expect(subject[user.id]).to eq(1)
+ end
+ end
+
+ context 'when user is part of sub group' do
+ let_it_be(:sub_group) { create(:group, parent: create(:group)) }
+ let_it_be(:sub_group_member1) { create(:group_member, source: sub_group, user_id: user.id, access_level: Gitlab::Access::DEVELOPER) }
+
+ it 'includes sub group in the count' do
+ expect(subject[user.id]).to eq(2)
+ end
+ end
+
+ context 'when user is part of namespaced project' do
+ let_it_be(:project) { create(:project, group: create(:group)) }
+ let_it_be(:project_member) { create(:project_member, source: project, user_id: user.id, access_level: Gitlab::Access::REPORTER) }
+
+ it 'includes the project group' do
+ expect(subject[user.id]).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/fixtures/api/schemas/entities/test_case.json b/spec/fixtures/api/schemas/entities/test_case.json
index d731d7eed0a..483fa881f8c 100644
--- a/spec/fixtures/api/schemas/entities/test_case.json
+++ b/spec/fixtures/api/schemas/entities/test_case.json
@@ -12,7 +12,13 @@
"execution_time": { "type": "float" },
"system_output": { "type": ["string", "null"] },
"stack_trace": { "type": ["string", "null"] },
- "attachment_url": { "type": ["string", "null"] }
+ "attachment_url": { "type": ["string", "null"] },
+ "recent_failures": {
+ "oneOf": [
+ { "type": "null" },
+ { "$ref": "test_case/recent_failures.json" }
+ ]
+ }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/test_case/recent_failures.json b/spec/fixtures/api/schemas/entities/test_case/recent_failures.json
new file mode 100644
index 00000000000..7a753101472
--- /dev/null
+++ b/spec/fixtures/api/schemas/entities/test_case/recent_failures.json
@@ -0,0 +1,12 @@
+{
+ "type": "object",
+ "required": [
+ "count",
+ "base_branch"
+ ],
+ "properties": {
+ "count": { "type": "integer" },
+ "base_branch": { "type": "string" }
+ },
+ "additionalProperties": false
+}
diff --git a/spec/fixtures/api/schemas/environment.json b/spec/fixtures/api/schemas/environment.json
index 7c49b269994..b8c4253056e 100644
--- a/spec/fixtures/api/schemas/environment.json
+++ b/spec/fixtures/api/schemas/environment.json
@@ -16,6 +16,7 @@
],
"properties": {
"id": { "type": "integer" },
+ "global_id": { "type": "string" },
"name": { "type": "string" },
"state": { "type": "string" },
"external_url": { "$ref": "types/nullable_string.json" },
diff --git a/spec/fixtures/api/schemas/graphql/container_repositories.json b/spec/fixtures/api/schemas/graphql/container_repositories.json
new file mode 100644
index 00000000000..8e8982ff8c7
--- /dev/null
+++ b/spec/fixtures/api/schemas/graphql/container_repositories.json
@@ -0,0 +1,12 @@
+{
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["node"],
+ "properties": {
+ "node": {
+ "$ref": "./container_repository.json"
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/graphql/container_repository.json b/spec/fixtures/api/schemas/graphql/container_repository.json
new file mode 100644
index 00000000000..e252bedab82
--- /dev/null
+++ b/spec/fixtures/api/schemas/graphql/container_repository.json
@@ -0,0 +1,40 @@
+{
+ "type": "object",
+ "required": ["id", "name", "path", "location", "createdAt", "updatedAt", "tagsCount", "canDelete", "expirationPolicyCleanupStatus"],
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "expirationPolicyStartedAt": {
+ "type": ["string", "null"]
+ },
+ "status": {
+ "type": ["string", "null"]
+ },
+ "tagsCount": {
+ "type": "integer"
+ },
+ "canDelete": {
+ "type": "boolean"
+ },
+ "expirationPolicyCleanupStatus": {
+ "type": "string",
+ "enum": ["UNSCHEDULED", "SCHEDULED", "UNFINISHED", "ONGOING"]
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/graphql/container_repository_details.json b/spec/fixtures/api/schemas/graphql/container_repository_details.json
new file mode 100644
index 00000000000..3db91796fc6
--- /dev/null
+++ b/spec/fixtures/api/schemas/graphql/container_repository_details.json
@@ -0,0 +1,49 @@
+{
+ "type": "object",
+ "required": ["tags"],
+ "allOf": [{ "$ref": "./container_repository.json" }],
+ "properties": {
+ "tags": {
+ "type": "object",
+ "required": ["nodes"],
+ "properties": {
+ "nodes": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "required": ["name", "path", "location", "digest", "revision", "shortRevision", "totalSize", "createdAt", "canDelete"],
+ "properties": {
+ "name": {
+ "type": "string"
+ },
+ "path": {
+ "type": "string"
+ },
+ "location": {
+ "type": "string"
+ },
+ "digest": {
+ "type": "string"
+ },
+ "revision": {
+ "type": "string"
+ },
+ "shortRevision": {
+ "type": "string"
+ },
+ "totalSize": {
+ "type": "integer"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "canDelete": {
+ "type": "boolean"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/internal/pages/lookup_path.json b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
index b2b3d3f9d0a..9d81ea495f1 100644
--- a/spec/fixtures/api/schemas/internal/pages/lookup_path.json
+++ b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
@@ -14,8 +14,12 @@
"source": { "type": "object",
"required": ["type", "path"],
"properties" : {
- "type": { "type": "string", "enum": ["file"] },
- "path": { "type": "string" }
+ "type": { "type": "string", "enum": ["file", "zip"] },
+ "path": { "type": "string" },
+ "global_id": { "type": "string" },
+ "sha256": { "type": "string" },
+ "file_size": { "type": "integer" },
+ "file_count": { "type": ["integer", "null"] }
},
"additionalProperties": false
},
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/package_files.json b/spec/fixtures/api/schemas/public_api/v4/packages/package_files.json
index f057adba65c..93b6dcde080 100644
--- a/spec/fixtures/api/schemas/public_api/v4/packages/package_files.json
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/package_files.json
@@ -7,7 +7,10 @@
"id": { "type": "integer" },
"package_id": { "type": "integer" },
"file_name": { "type": "string" },
- "file_sha1": { "type": "string" }
+ "file_sha1": { "type": "string" },
+ "pipelines": {
+ "items": { "$ref": "../pipeline.json" }
+ }
}
}
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/packages/package_with_build.json b/spec/fixtures/api/schemas/public_api/v4/packages/package_with_build.json
index de3ef94138e..86b4e205c3e 100644
--- a/spec/fixtures/api/schemas/public_api/v4/packages/package_with_build.json
+++ b/spec/fixtures/api/schemas/public_api/v4/packages/package_with_build.json
@@ -5,6 +5,9 @@
"name": { "type": "string" },
"version": { "type": "string" },
"package_type": { "type": "string" },
- "pipeline": { "$ref": "../pipeline.json" }
+ "pipeline": { "$ref": "../pipeline.json" },
+ "pipelines": {
+ "items": { "$ref": "../pipeline.json" }
+ }
}
}
diff --git a/spec/fixtures/api/schemas/public_api/v4/release.json b/spec/fixtures/api/schemas/public_api/v4/release.json
index 02e23d2732d..69ac383b7fd 100644
--- a/spec/fixtures/api/schemas/public_api/v4/release.json
+++ b/spec/fixtures/api/schemas/public_api/v4/release.json
@@ -21,7 +21,6 @@
},
"commit_path": { "type": "string" },
"tag_path": { "type": "string" },
- "name": { "type": "string" },
"evidences": {
"type": "array",
"items": { "$ref": "release/evidence.json" }
@@ -42,11 +41,8 @@
"additionalProperties": false
},
"_links": {
- "required": ["merge_requests_url", "issues_url"],
"properties": {
- "merge_requests_url": { "type": "string" },
- "issues_url": { "type": "string" },
- "edit_url": { "type": "string"}
+ "edit_url": { "type": "string" }
}
}
},
diff --git a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
index 1a1e92ac778..058b7b4b4ed 100644
--- a/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
+++ b/spec/fixtures/api/schemas/public_api/v4/release/release_for_guest.json
@@ -26,11 +26,7 @@
"additionalProperties": false
},
"_links": {
- "required": ["merge_requests_url", "issues_url"],
- "properties": {
- "merge_requests_url": { "type": "string" },
- "issues_url": { "type": "string" }
- }
+ "properties": {}
}
},
"additionalProperties": false
diff --git a/spec/fixtures/csv_no_headers.csv b/spec/fixtures/csv_no_headers.csv
new file mode 100644
index 00000000000..364f7e8f1ae
--- /dev/null
+++ b/spec/fixtures/csv_no_headers.csv
@@ -0,0 +1,3 @@
+Issue in 中文,Test description
+"Hello","World"
+"Title with quote""",Description
diff --git a/spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz b/spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz
new file mode 100644
index 00000000000..14043720eaf
--- /dev/null
+++ b/spec/fixtures/dependency_proxy/a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4.gz
Binary files differ
diff --git a/spec/fixtures/domain_blacklist.txt b/spec/fixtures/domain_denylist.txt
index baeb11eda9a..baeb11eda9a 100644
--- a/spec/fixtures/domain_blacklist.txt
+++ b/spec/fixtures/domain_denylist.txt
diff --git a/spec/fixtures/junit/junit_with_duplicate_failed_test_names.xml.gz b/spec/fixtures/junit/junit_with_duplicate_failed_test_names.xml.gz
new file mode 100644
index 00000000000..9bcd06759da
--- /dev/null
+++ b/spec/fixtures/junit/junit_with_duplicate_failed_test_names.xml.gz
Binary files differ
diff --git a/spec/fixtures/junit/junit_with_three_failures.xml.gz b/spec/fixtures/junit/junit_with_three_failures.xml.gz
new file mode 100644
index 00000000000..8ea9a9db437
--- /dev/null
+++ b/spec/fixtures/junit/junit_with_three_failures.xml.gz
Binary files differ
diff --git a/spec/fixtures/lib/gitlab/import_export/complex/project.json b/spec/fixtures/lib/gitlab/import_export/complex/project.json
index d88b2ebc83a..637e01bf123 100644
--- a/spec/fixtures/lib/gitlab/import_export/complex/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/complex/project.json
@@ -7211,5 +7211,23 @@
}
]
}
- ]
+ ],
+ "push_rule": {
+ "force_push_regex": "MustContain",
+ "delete_branch_regex": "MustContain",
+ "commit_message_regex": "MustContain",
+ "author_email_regex": "MustContain",
+ "file_name_regex": "MustContain",
+ "branch_name_regex": "MustContain",
+ "commit_message_negative_regex": "MustNotContain",
+ "max_file_size": 1,
+ "deny_delete_tag": true,
+ "member_check": true,
+ "is_sample": true,
+ "prevent_secrets": true,
+ "reject_unsigned_commits": true,
+ "commit_committer_check": true,
+ "regexp_uses_re2": true
+ }
+
}
diff --git a/spec/fixtures/lib/gitlab/import_export/designs/project.json b/spec/fixtures/lib/gitlab/import_export/designs/project.json
index ebc08868d9e..e11b10a1d4c 100644
--- a/spec/fixtures/lib/gitlab/import_export/designs/project.json
+++ b/spec/fixtures/lib/gitlab/import_export/designs/project.json
@@ -98,6 +98,7 @@
"designs":[
{
"id":38,
+ "iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg",
@@ -107,6 +108,7 @@
},
{
"id":39,
+ "iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg",
@@ -116,6 +118,7 @@
},
{
"id":40,
+ "iid": 3,
"project_id":30,
"issue_id":469,
"filename":"mariavontrap.jpeg",
@@ -137,6 +140,7 @@
"event":0,
"design":{
"id":38,
+ "iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@@ -156,6 +160,7 @@
"event":1,
"design":{
"id":38,
+ "iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@@ -167,6 +172,7 @@
"event":0,
"design":{
"id":39,
+ "iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg"
@@ -186,6 +192,7 @@
"event":1,
"design":{
"id":38,
+ "iid": 1,
"project_id":30,
"issue_id":469,
"filename":"chirrido3.jpg"
@@ -197,6 +204,7 @@
"event":2,
"design":{
"id":39,
+ "iid": 2,
"project_id":30,
"issue_id":469,
"filename":"jonathan_richman.jpg"
@@ -208,6 +216,7 @@
"event":0,
"design":{
"id":40,
+ "iid": 3,
"project_id":30,
"issue_id":469,
"filename":"mariavontrap.jpeg"
diff --git a/spec/fixtures/packages/debian/README.md b/spec/fixtures/packages/debian/README.md
new file mode 100644
index 00000000000..e398222ce62
--- /dev/null
+++ b/spec/fixtures/packages/debian/README.md
@@ -0,0 +1,21 @@
+# Build a Debian package
+
+Install the build dependencies:
+
+```shell
+sudo apt install dpkg-dev
+```
+
+Go to the `spec/fixtures/packages/debian` directory and clean up old files:
+
+```shell
+cd spec/fixtures/packages/debian
+rm -v *.tar.* *.dsc *.deb *.udeb *.buildinfo *.changes
+```
+
+Go to the package source directory and build:
+
+```shell
+cd sample
+dpkg-buildpackage --no-sign
+```
diff --git a/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb b/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb
deleted file mode 100644
index c6cac69265a..00000000000
--- a/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2-1_amd64.deb
+++ /dev/null
@@ -1 +0,0 @@
-empty
diff --git a/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2_amd64.deb b/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2_amd64.deb
new file mode 100644
index 00000000000..e428f8fcf76
--- /dev/null
+++ b/spec/fixtures/packages/debian/libsample0_1.2.3~alpha2_amd64.deb
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample-dev_1.2.3~binary_amd64.deb b/spec/fixtures/packages/debian/sample-dev_1.2.3~binary_amd64.deb
new file mode 100644
index 00000000000..70567d75265
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample-dev_1.2.3~binary_amd64.deb
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample-udeb_1.2.3~alpha2_amd64.udeb b/spec/fixtures/packages/debian/sample-udeb_1.2.3~alpha2_amd64.udeb
new file mode 100644
index 00000000000..b69692a26d4
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample-udeb_1.2.3~alpha2_amd64.udeb
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample/debian/.gitignore b/spec/fixtures/packages/debian/sample/debian/.gitignore
new file mode 100644
index 00000000000..cb63a746c89
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample/debian/.gitignore
@@ -0,0 +1,8 @@
+.debhelper
+debhelper-build-stamp
+files
+*.substvars
+libsample0
+sample-dev
+sample-udeb
+
diff --git a/spec/fixtures/packages/debian/sample/debian/changelog b/spec/fixtures/packages/debian/sample/debian/changelog
new file mode 100644
index 00000000000..5c20ee43c36
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample/debian/changelog
@@ -0,0 +1,5 @@
+sample (1.2.3~alpha2) unstable; urgency=medium
+
+ * Initial release
+
+ -- John Doe <john.doe@example.com> Thu, 01 Oct 2020 09:35:15 +0200
diff --git a/spec/fixtures/packages/debian/sample/debian/control b/spec/fixtures/packages/debian/sample/debian/control
new file mode 100644
index 00000000000..168d8e20d62
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample/debian/control
@@ -0,0 +1,35 @@
+Source: sample
+Priority: optional
+Maintainer: John Doe <john.doe@example.com>
+Build-Depends: debhelper-compat (= 13)
+Standards-Version: 4.5.0
+Section: libs
+Homepage: https://gitlab.com/
+#Vcs-Browser: https://salsa.debian.org/debian/sample-1.2.3
+#Vcs-Git: https://salsa.debian.org/debian/sample-1.2.3.git
+Rules-Requires-Root: no
+
+Package: sample-dev
+Section: libdevel
+Architecture: any
+Multi-Arch: same
+Depends: libsample0 (= ${binary:Version}), ${misc:Depends}
+Description: Some mostly empty developpement files
+ Used in GitLab tests.
+ .
+ Testing another paragraph.
+
+Package: libsample0
+Architecture: any
+Multi-Arch: same
+Depends: ${shlibs:Depends}, ${misc:Depends}
+Description: Some mostly empty lib
+ Used in GitLab tests.
+ .
+ Testing another paragraph.
+
+Package: sample-udeb
+Package-Type: udeb
+Architecture: any
+Depends: installed-base
+Description: Some mostly empty udeb
diff --git a/spec/fixtures/packages/debian/sample/debian/rules b/spec/fixtures/packages/debian/sample/debian/rules
new file mode 100755
index 00000000000..8ae87843489
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample/debian/rules
@@ -0,0 +1,6 @@
+#!/usr/bin/make -f
+%:
+ dh $@
+override_dh_gencontrol:
+ dh_gencontrol -psample-dev -- -v'1.2.3~binary'
+ dh_gencontrol --remaining-packages
diff --git a/spec/fixtures/packages/debian/sample/debian/source/format b/spec/fixtures/packages/debian/sample/debian/source/format
new file mode 100644
index 00000000000..89ae9db8f88
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample/debian/source/format
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc
new file mode 100644
index 00000000000..164d4755ed4
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.dsc
@@ -0,0 +1,19 @@
+Format: 3.0 (native)
+Source: sample
+Binary: sample-dev, libsample0, sample-udeb
+Architecture: any
+Version: 1.2.3~alpha2
+Maintainer: John Doe <john.doe@example.com>
+Homepage: https://gitlab.com/
+Standards-Version: 4.5.0
+Build-Depends: debhelper-compat (= 13)
+Package-List:
+ libsample0 deb libs optional arch=any
+ sample-dev deb libdevel optional arch=any
+ sample-udeb udeb libs optional arch=any
+Checksums-Sha1:
+ 5f8bba5574eb01ac3b1f5e2988e8c29307788236 864 sample_1.2.3~alpha2.tar.xz
+Checksums-Sha256:
+ b5a599e88e7cbdda3bde808160a21ba1dd1ec76b2ec8d4912aae769648d68362 864 sample_1.2.3~alpha2.tar.xz
+Files:
+ d79b34f58f61ff4ad696d9bd0b8daa68 864 sample_1.2.3~alpha2.tar.xz
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz
new file mode 100644
index 00000000000..da70fd2094f
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2.tar.xz
Binary files differ
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo
new file mode 100644
index 00000000000..dd63727ba31
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.buildinfo
@@ -0,0 +1,180 @@
+Format: 1.0
+Source: sample
+Binary: libsample0 sample-dev sample-udeb
+Architecture: amd64 source
+Version: 1.2.3~alpha2
+Checksums-Md5:
+ 3b0817804f669e16cdefac583ad88f0e 671 sample_1.2.3~alpha2.dsc
+ fb0842b21adc44207996296fe14439dd 1124 libsample0_1.2.3~alpha2_amd64.deb
+ d2afbd28e4d74430d22f9504e18bfdf5 1164 sample-dev_1.2.3~binary_amd64.deb
+ 72b1dd7d98229e2fb0355feda1d3a165 736 sample-udeb_1.2.3~alpha2_amd64.udeb
+Checksums-Sha1:
+ 32ecbd674f0bfd310df68484d87752490685a8d6 671 sample_1.2.3~alpha2.dsc
+ 5248b95600e85bfe7f63c0dfce330a75f5777366 1124 libsample0_1.2.3~alpha2_amd64.deb
+ f81e4f66c8c6bb899653a3340c157965ee69634f 1164 sample-dev_1.2.3~binary_amd64.deb
+ e42e8f2fe04ed1bb73b44a187674480d0e49dcba 736 sample-udeb_1.2.3~alpha2_amd64.udeb
+Checksums-Sha256:
+ 844f79825b7e8aaa191e514b58a81f9ac1e58e2180134b0c9512fa66d896d7ba 671 sample_1.2.3~alpha2.dsc
+ 1c383a525bfcba619c7305ccd106d61db501a6bbaf0003bf8d0c429fbdb7fcc1 1124 libsample0_1.2.3~alpha2_amd64.deb
+ 9fbeee2191ce4dab5288fad5ecac1bd369f58fef9a992a880eadf0caf25f086d 1164 sample-dev_1.2.3~binary_amd64.deb
+ 2b0c152b3ab4cc07663350424de972c2b7621d69fe6df2e0b94308a191e4632f 736 sample-udeb_1.2.3~alpha2_amd64.udeb
+Build-Origin: Debian
+Build-Architecture: amd64
+Build-Date: Thu, 08 Oct 2020 15:15:24 +0200
+Build-Tainted-By:
+ merged-usr-via-symlinks
+ usr-local-has-includes
+ usr-local-has-libraries
+ usr-local-has-programs
+Installed-Build-Depends:
+ autoconf (= 2.69-11.1),
+ automake (= 1:1.16.2-4),
+ autopoint (= 0.19.8.1-10),
+ autotools-dev (= 20180224.1),
+ base-files (= 11),
+ base-passwd (= 3.5.47),
+ bash (= 5.0-7),
+ binutils (= 2.35.1-1),
+ binutils-common (= 2.35.1-1),
+ binutils-x86-64-linux-gnu (= 2.35.1-1),
+ bsdextrautils (= 2.36-3+b1),
+ bsdmainutils (= 12.1.7),
+ bsdutils (= 1:2.36-3+b1),
+ build-essential (= 12.8),
+ bzip2 (= 1.0.8-4),
+ calendar (= 12.1.7),
+ coreutils (= 8.32-4+b1),
+ cpp (= 4:10.2.0-1),
+ cpp-10 (= 10.2.0-9),
+ cpp-9 (= 9.3.0-18),
+ dash (= 0.5.10.2-7),
+ debconf (= 1.5.74),
+ debhelper (= 13.2.1),
+ debianutils (= 4.11.2),
+ dh-autoreconf (= 19),
+ dh-strip-nondeterminism (= 1.9.0-1),
+ diffutils (= 1:3.7-3),
+ dpkg (= 1.20.5),
+ dpkg-dev (= 1.20.5),
+ dwz (= 0.13-5),
+ file (= 1:5.38-5),
+ findutils (= 4.7.0-1),
+ g++ (= 4:10.2.0-1),
+ g++-10 (= 10.2.0-9),
+ gcc (= 4:10.2.0-1),
+ gcc-10 (= 10.2.0-9),
+ gcc-10-base (= 10.2.0-9),
+ gcc-9 (= 9.3.0-18),
+ gcc-9-base (= 9.3.0-18),
+ gettext (= 0.19.8.1-10),
+ gettext-base (= 0.19.8.1-10),
+ grep (= 3.4-1),
+ groff-base (= 1.22.4-5),
+ gzip (= 1.10-2),
+ hostname (= 3.23),
+ init-system-helpers (= 1.58),
+ intltool-debian (= 0.35.0+20060710.5),
+ libacl1 (= 2.2.53-8),
+ libarchive-zip-perl (= 1.68-1),
+ libasan5 (= 9.3.0-18),
+ libasan6 (= 10.2.0-9),
+ libatomic1 (= 10.2.0-9),
+ libattr1 (= 1:2.4.48-5),
+ libaudit-common (= 1:2.8.5-3),
+ libaudit1 (= 1:2.8.5-3+b1),
+ libbinutils (= 2.35.1-1),
+ libblkid1 (= 2.36-3+b1),
+ libbsd0 (= 0.10.0-1),
+ libbz2-1.0 (= 1.0.8-4),
+ libc-bin (= 2.31-3),
+ libc-dev-bin (= 2.31-3),
+ libc6 (= 2.31-3),
+ libc6-dev (= 2.31-3),
+ libcap-ng0 (= 0.7.9-2.2),
+ libcc1-0 (= 10.2.0-9),
+ libcroco3 (= 0.6.13-1),
+ libcrypt-dev (= 1:4.4.17-1),
+ libcrypt1 (= 1:4.4.17-1),
+ libctf-nobfd0 (= 2.35.1-1),
+ libctf0 (= 2.35.1-1),
+ libdb5.3 (= 5.3.28+dfsg1-0.6),
+ libdebconfclient0 (= 0.254),
+ libdebhelper-perl (= 13.2.1),
+ libdpkg-perl (= 1.20.5),
+ libelf1 (= 0.181-1),
+ libffi7 (= 3.3-4),
+ libfile-stripnondeterminism-perl (= 1.9.0-1),
+ libgcc-10-dev (= 10.2.0-9),
+ libgcc-9-dev (= 9.3.0-18),
+ libgcc-s1 (= 10.2.0-9),
+ libgcrypt20 (= 1.8.6-2),
+ libgdbm-compat4 (= 1.18.1-5.1),
+ libgdbm6 (= 1.18.1-5.1),
+ libglib2.0-0 (= 2.66.0-2),
+ libgmp10 (= 2:6.2.0+dfsg-6),
+ libgomp1 (= 10.2.0-9),
+ libgpg-error0 (= 1.38-2),
+ libicu67 (= 67.1-4),
+ libisl22 (= 0.22.1-1),
+ libitm1 (= 10.2.0-9),
+ liblsan0 (= 10.2.0-9),
+ liblz4-1 (= 1.9.2-2),
+ liblzma5 (= 5.2.4-1+b1),
+ libmagic-mgc (= 1:5.38-5),
+ libmagic1 (= 1:5.38-5),
+ libmount1 (= 2.36-3+b1),
+ libmpc3 (= 1.2.0-1),
+ libmpfr6 (= 4.1.0-3),
+ libpam-modules (= 1.3.1-5),
+ libpam-modules-bin (= 1.3.1-5),
+ libpam-runtime (= 1.3.1-5),
+ libpam0g (= 1.3.1-5),
+ libpcre2-8-0 (= 10.34-7),
+ libpcre3 (= 2:8.39-13),
+ libperl5.30 (= 5.30.3-4),
+ libpipeline1 (= 1.5.3-1),
+ libquadmath0 (= 10.2.0-9),
+ libseccomp2 (= 2.4.4-1),
+ libselinux1 (= 3.1-2),
+ libsigsegv2 (= 2.12-2),
+ libsmartcols1 (= 2.36-3+b1),
+ libstdc++-10-dev (= 10.2.0-9),
+ libstdc++6 (= 10.2.0-9),
+ libsub-override-perl (= 0.09-2),
+ libsystemd0 (= 246.6-1),
+ libtinfo6 (= 6.2+20200918-1),
+ libtool (= 2.4.6-14),
+ libtsan0 (= 10.2.0-9),
+ libubsan1 (= 10.2.0-9),
+ libuchardet0 (= 0.0.7-1),
+ libudev1 (= 246.6-1),
+ libunistring2 (= 0.9.10-4),
+ libuuid1 (= 2.36-3+b1),
+ libxml2 (= 2.9.10+dfsg-6),
+ libzstd1 (= 1.4.5+dfsg-4),
+ linux-libc-dev (= 5.8.10-1),
+ login (= 1:4.8.1-1),
+ lsb-base (= 11.1.0),
+ m4 (= 1.4.18-4),
+ make (= 4.3-4),
+ man-db (= 2.9.3-2),
+ mawk (= 1.3.4.20200120-2),
+ ncal (= 12.1.7),
+ ncurses-base (= 6.2+20200918-1),
+ ncurses-bin (= 6.2+20200918-1),
+ patch (= 2.7.6-6),
+ perl (= 5.30.3-4),
+ perl-base (= 5.30.3-4),
+ perl-modules-5.30 (= 5.30.3-4),
+ po-debconf (= 1.0.21),
+ sed (= 4.7-1),
+ sensible-utils (= 0.0.12+nmu1),
+ sysvinit-utils (= 2.96-5),
+ tar (= 1.30+dfsg-7),
+ util-linux (= 2.36-3+b1),
+ xz-utils (= 5.2.4-1+b1),
+ zlib1g (= 1:1.2.11.dfsg-2)
+Environment:
+ DEB_BUILD_OPTIONS="parallel=8"
+ LANG="fr_FR.UTF-8"
+ SOURCE_DATE_EPOCH="1601537715"
diff --git a/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes
new file mode 100644
index 00000000000..7a6bb78eb78
--- /dev/null
+++ b/spec/fixtures/packages/debian/sample_1.2.3~alpha2_amd64.changes
@@ -0,0 +1,39 @@
+Format: 1.8
+Date: Thu, 01 Oct 2020 09:35:15 +0200
+Source: sample
+Binary: libsample0 sample-dev sample-udeb
+Architecture: source amd64
+Version: 1.2.3~alpha2
+Distribution: unstable
+Urgency: medium
+Maintainer: John Doe <john.doe@example.com>
+Changed-By: John Doe <john.doe@example.com>
+Description:
+ libsample0 - Some mostly empty lib
+ sample-dev - Some mostly empty developpement files
+ sample-udeb - Some mostly empty udeb (udeb)
+Changes:
+ sample (1.2.3~alpha2) unstable; urgency=medium
+ .
+ * Initial release
+Checksums-Sha1:
+ 32ecbd674f0bfd310df68484d87752490685a8d6 671 sample_1.2.3~alpha2.dsc
+ 5f8bba5574eb01ac3b1f5e2988e8c29307788236 864 sample_1.2.3~alpha2.tar.xz
+ 5248b95600e85bfe7f63c0dfce330a75f5777366 1124 libsample0_1.2.3~alpha2_amd64.deb
+ f81e4f66c8c6bb899653a3340c157965ee69634f 1164 sample-dev_1.2.3~binary_amd64.deb
+ e42e8f2fe04ed1bb73b44a187674480d0e49dcba 736 sample-udeb_1.2.3~alpha2_amd64.udeb
+ 0d47e899f3cc67a2253a4629456ff927e0db5c60 5280 sample_1.2.3~alpha2_amd64.buildinfo
+Checksums-Sha256:
+ 844f79825b7e8aaa191e514b58a81f9ac1e58e2180134b0c9512fa66d896d7ba 671 sample_1.2.3~alpha2.dsc
+ b5a599e88e7cbdda3bde808160a21ba1dd1ec76b2ec8d4912aae769648d68362 864 sample_1.2.3~alpha2.tar.xz
+ 1c383a525bfcba619c7305ccd106d61db501a6bbaf0003bf8d0c429fbdb7fcc1 1124 libsample0_1.2.3~alpha2_amd64.deb
+ 9fbeee2191ce4dab5288fad5ecac1bd369f58fef9a992a880eadf0caf25f086d 1164 sample-dev_1.2.3~binary_amd64.deb
+ 2b0c152b3ab4cc07663350424de972c2b7621d69fe6df2e0b94308a191e4632f 736 sample-udeb_1.2.3~alpha2_amd64.udeb
+ f9900d3c94e94b329232668dcbef3dba2d96c07147b15b6dc0533452e4dd8a43 5280 sample_1.2.3~alpha2_amd64.buildinfo
+Files:
+ 3b0817804f669e16cdefac583ad88f0e 671 libs optional sample_1.2.3~alpha2.dsc
+ d79b34f58f61ff4ad696d9bd0b8daa68 864 libs optional sample_1.2.3~alpha2.tar.xz
+ fb0842b21adc44207996296fe14439dd 1124 libs optional libsample0_1.2.3~alpha2_amd64.deb
+ d2afbd28e4d74430d22f9504e18bfdf5 1164 libdevel optional sample-dev_1.2.3~binary_amd64.deb
+ 72b1dd7d98229e2fb0355feda1d3a165 736 libs optional sample-udeb_1.2.3~alpha2_amd64.udeb
+ 4e085dd67c120ca967ec314f65770a42 5280 libs optional sample_1.2.3~alpha2_amd64.buildinfo
diff --git a/spec/fixtures/whats_new/01.yml b/spec/fixtures/whats_new/20201225_01_01.yml
index 06db95be44f..06db95be44f 100644
--- a/spec/fixtures/whats_new/01.yml
+++ b/spec/fixtures/whats_new/20201225_01_01.yml
diff --git a/spec/fixtures/whats_new/02.yml b/spec/fixtures/whats_new/20201225_01_02.yml
index 91b0bd7036e..91b0bd7036e 100644
--- a/spec/fixtures/whats_new/02.yml
+++ b/spec/fixtures/whats_new/20201225_01_02.yml
diff --git a/spec/fixtures/whats_new/05.yml b/spec/fixtures/whats_new/20201225_01_05.yml
index 5b8939a2bc6..7c95e386f00 100644
--- a/spec/fixtures/whats_new/05.yml
+++ b/spec/fixtures/whats_new/20201225_01_05.yml
@@ -1,2 +1,3 @@
---
- title: bright and sunshinin' day
+ release: '01.05'
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js
index 237f8b408f5..94e3f624c25 100644
--- a/spec/frontend/__mocks__/@gitlab/ui.js
+++ b/spec/frontend/__mocks__/@gitlab/ui.js
@@ -18,7 +18,7 @@ jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({
}));
jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({
- props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled'],
+ props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled', 'show'],
render(h) {
return h(
'div',
@@ -38,8 +38,16 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({
required: false,
default: () => [],
},
+ ...Object.fromEntries(['target', 'triggers', 'placement'].map(prop => [prop, {}])),
},
render(h) {
- return h('div', this.$attrs, Object.keys(this.$slots).map(s => this.$slots[s]));
+ return h(
+ 'div',
+ {
+ class: 'gl-popover',
+ ...this.$attrs,
+ },
+ Object.keys(this.$slots).map(s => this.$slots[s]),
+ );
},
}));
diff --git a/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
new file mode 100644
index 00000000000..33c29cea6d8
--- /dev/null
+++ b/spec/frontend/access_tokens/components/__snapshots__/expires_at_field_spec.js.snap
@@ -0,0 +1,24 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/access_tokens/components/expires_at_field should render datepicker with input info 1`] = `
+<gl-datepicker-stub
+ ariallabel=""
+ autocomplete=""
+ container=""
+ displayfield="true"
+ firstday="0"
+ mindate="Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"
+ placeholder="YYYY-MM-DD"
+ theme=""
+>
+ <gl-form-input-stub
+ autocomplete="off"
+ class="datepicker gl-datepicker-input"
+ data-qa-selector="expiry_date_field"
+ id="personal_access_token_expires_at"
+ inputmode="none"
+ name="personal_access_token[expires_at]"
+ placeholder="YYYY-MM-DD"
+ />
+</gl-datepicker-stub>
+`;
diff --git a/spec/frontend/access_tokens/components/expires_at_field_spec.js b/spec/frontend/access_tokens/components/expires_at_field_spec.js
new file mode 100644
index 00000000000..cd235d0afa5
--- /dev/null
+++ b/spec/frontend/access_tokens/components/expires_at_field_spec.js
@@ -0,0 +1,34 @@
+import { shallowMount } from '@vue/test-utils';
+import { useFakeDate } from 'helpers/fake_date';
+import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
+
+describe('~/access_tokens/components/expires_at_field', () => {
+ useFakeDate();
+
+ let wrapper;
+
+ const createComponent = () => {
+ wrapper = shallowMount(ExpiresAtField, {
+ propsData: {
+ inputAttrs: {
+ id: 'personal_access_token_expires_at',
+ name: 'personal_access_token[expires_at]',
+ placeholder: 'YYYY-MM-DD',
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render datepicker with input info', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js
index f3ebdfc5cc2..e2d913398f9 100644
--- a/spec/frontend/alert_management/components/alert_details_spec.js
+++ b/spec/frontend/alert_management/components/alert_details_spec.js
@@ -20,11 +20,7 @@ const environmentName = 'Production';
const environmentPath = '/fake/path';
describe('AlertDetails', () => {
- let environmentData = {
- name: environmentName,
- path: environmentPath,
- };
- let glFeatures = { exposeEnvironmentPathInAlertDetails: false };
+ let environmentData = { name: environmentName, path: environmentPath };
let mock;
let wrapper;
const projectPath = 'root/alerts';
@@ -40,7 +36,6 @@ describe('AlertDetails', () => {
projectPath,
projectIssuesPath,
projectId,
- glFeatures,
},
data() {
return {
@@ -159,33 +154,21 @@ describe('AlertDetails', () => {
});
describe('environment fields', () => {
- describe('when exposeEnvironmentPathInAlertDetails is disabled', () => {
- beforeEach(mountComponent);
+ it('should show the environment name with a link to the path', () => {
+ mountComponent();
+ const path = findEnvironmentPath();
- it('should not show the environment', () => {
- expect(findEnvironmentName().exists()).toBe(false);
- expect(findEnvironmentPath().exists()).toBe(false);
- });
+ expect(findEnvironmentName().exists()).toBe(false);
+ expect(path.text()).toBe(environmentName);
+ expect(path.attributes('href')).toBe(environmentPath);
});
- describe('when exposeEnvironmentPathInAlertDetails is enabled', () => {
- beforeEach(() => {
- glFeatures = { exposeEnvironmentPathInAlertDetails: true };
- mountComponent();
- });
-
- it('should show the environment name with link to path', () => {
- expect(findEnvironmentName().exists()).toBe(false);
- expect(findEnvironmentPath().text()).toBe(environmentName);
- expect(findEnvironmentPath().attributes('href')).toBe(environmentPath);
- });
+ it('should only show the environment name if the path is not provided', () => {
+ environmentData = { name: environmentName, path: null };
+ mountComponent();
- it('should only show the environment name if the path is not provided', () => {
- environmentData = { name: environmentName, path: null };
- mountComponent();
- expect(findEnvironmentPath().exists()).toBe(false);
- expect(findEnvironmentName().text()).toBe(environmentName);
- });
+ expect(findEnvironmentPath().exists()).toBe(false);
+ expect(findEnvironmentName().text()).toBe(environmentName);
});
});
@@ -195,6 +178,7 @@ describe('AlertDetails', () => {
mountComponent({
data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false },
});
+
expect(findViewIncidentBtn().exists()).toBe(true);
expect(findViewIncidentBtn().attributes('href')).toBe(
joinPaths(projectIssuesPath, issueIid),
@@ -220,8 +204,8 @@ describe('AlertDetails', () => {
jest
.spyOn(wrapper.vm.$apollo, 'mutate')
.mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } });
-
findCreateIncidentBtn().trigger('click');
+
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: createIssueMutation,
variables: {
@@ -251,6 +235,7 @@ describe('AlertDetails', () => {
beforeEach(() => {
mountComponent({ data: { alert: mockAlert } });
});
+
it('should display a table of raw alert details data', () => {
expect(findDetailsTable().exists()).toBe(true);
});
diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
deleted file mode 100644
index 5800b160efe..00000000000
--- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap
+++ /dev/null
@@ -1,50 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AlertsSettingsForm with default values renders the initial template 1`] = `
-"<div>
- <integrations-list-stub integrations=\\"[object Object],[object Object]\\"></integrations-list-stub>
- <gl-form-stub>
- <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5>
- <!---->
- <div data-testid=\\"alert-settings-description\\">
- <p>
- <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub>
- </p>
- <p>
- <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub>
- </p>
- </div>
- <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\">
- <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our improvements for %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span>
- </gl-form-group-stub>
- <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\">
- <toggle-button-stub id=\\"activated\\"></toggle-button-stub>
- </gl-form-group-stub>
- <!---->
- <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\">
- <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\">
-
- </span>
- </gl-form-group-stub>
- <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\">
- <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
- <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
- Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
- </gl-modal-stub>
- </gl-form-group-stub>
- <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\">
- <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub>
- </gl-form-group-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
- <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\">
- <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
- Save changes
- </gl-button-stub>
- <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
- Cancel
- </gl-button-stub>
- </div>
- </gl-form-stub>
-</div>"
-`;
diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap
new file mode 100644
index 00000000000..e2ef7483316
--- /dev/null
+++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_new_spec.js.snap
@@ -0,0 +1,97 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AlertsSettingsFormNew with default values renders the initial template 1`] = `
+"<form class=\\"gl-mt-6\\">
+ <h5 class=\\"gl-font-lg gl-my-5\\">Add new integrations</h5>
+ <div id=\\"integration-type\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-type__BV_label_\\" for=\\"integration-type\\" class=\\"d-block col-form-label\\">1. Select integration type</label>
+ <div class=\\"bv-no-focus-ring\\"><select class=\\"gl-form-select custom-select\\" id=\\"__BVID__8\\">
+ <option value=\\"\\">Select integration type</option>
+ <option value=\\"HTTP\\">HTTP Endpoint</option>
+ <option value=\\"PROMETHEUS\\">External Prometheus</option>
+ <option value=\\"OPSGENIE\\">Opsgenie</option>
+ </select>
+ <!---->
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </div>
+ <div class=\\"gl-mt-3 collapse\\" style=\\"display: none;\\" id=\\"__BVID__10\\">
+ <div>
+ <div id=\\"name-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"name-integration__BV_label_\\" for=\\"name-integration\\" class=\\"d-block col-form-label\\">2. Name integration</label>
+ <div class=\\"bv-no-focus-ring\\"><input type=\\"text\\" placeholder=\\"Enter integration name\\" class=\\"gl-form-input form-control\\" id=\\"__BVID__15\\">
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </div>
+ <div id=\\"integration-webhook\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"integration-webhook__BV_label_\\" for=\\"integration-webhook\\" class=\\"d-block col-form-label\\">3. Set up webhook</label>
+ <div class=\\"bv-no-focus-ring\\"><span>Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the <a rel=\\"noopener noreferrer\\" target=\\"_blank\\" href=\\"https://docs.gitlab.com/ee/operations/incident_management/alert_integrations.html\\" class=\\"gl-link gl-display-inline-block\\">GitLab documentation</a> to learn more about configuring your endpoint.</span> <label class=\\"gl-display-flex gl-flex-direction-column gl-mb-0 gl-w-max-content gl-my-4 gl-font-weight-normal\\">
+ <div class=\\"gl-toggle-wrapper\\"><span class=\\"gl-toggle-label\\">Active</span>
+ <!----> <button aria-label=\\"Active\\" type=\\"button\\" class=\\"gl-toggle\\"><span class=\\"toggle-icon\\"><svg data-testid=\\"close-icon\\" class=\\"gl-icon s16\\"><use href=\\"#close\\"></use></svg></span></button></div>
+ <!---->
+ </label>
+ <!---->
+ <div class=\\"gl-my-4\\"><span class=\\"gl-font-weight-bold\\">
+ Webhook URL
+ </span>
+ <div id=\\"url\\" readonly=\\"readonly\\">
+ <div role=\\"group\\" class=\\"input-group\\">
+ <!---->
+ <!----> <input id=\\"url\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\">
+ <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\">
+ <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\">
+ <use href=\\"#copy-to-clipboard\\"></use>
+ </svg>
+ <!----></button></div>
+ <!---->
+ </div>
+ </div>
+ </div>
+ <div class=\\"gl-my-4\\"><span class=\\"gl-font-weight-bold\\">
+ Authorization key
+ </span>
+ <div id=\\"authorization-key\\" readonly=\\"readonly\\" class=\\"gl-mb-3\\">
+ <div role=\\"group\\" class=\\"input-group\\">
+ <!---->
+ <!----> <input id=\\"authorization-key\\" type=\\"text\\" readonly=\\"readonly\\" class=\\"gl-form-input form-control\\">
+ <div class=\\"input-group-append\\"><button title=\\"Copy\\" data-clipboard-text=\\"\\" aria-label=\\"Copy this value\\" type=\\"button\\" class=\\"btn gl-m-0! btn-default btn-md gl-button btn-default-secondary btn-icon\\">
+ <!----> <svg data-testid=\\"copy-to-clipboard-icon\\" class=\\"gl-button-icon gl-icon s16\\">
+ <use href=\\"#copy-to-clipboard\\"></use>
+ </svg>
+ <!----></button></div>
+ <!---->
+ </div>
+ </div> <button type=\\"button\\" disabled=\\"disabled\\" class=\\"btn btn-default btn-md disabled gl-button\\">
+ <!---->
+ <!----> <span class=\\"gl-button-text\\">
+ Reset Key
+ </span></button>
+ <!---->
+ </div>
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </div>
+ <div id=\\"test-integration\\" role=\\"group\\" class=\\"form-group gl-form-group\\"><label id=\\"test-integration__BV_label_\\" for=\\"test-integration\\" class=\\"d-block col-form-label\\">4. Sample alert payload (optional)</label>
+ <div class=\\"bv-no-focus-ring\\"><span>Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).</span> <textarea id=\\"test-payload\\" disabled=\\"disabled\\" placeholder=\\"{ &quot;events&quot;: [{ &quot;application&quot;: &quot;Name of application&quot; }] }\\" wrap=\\"soft\\" class=\\"gl-form-input gl-form-textarea gl-my-3 form-control is-valid\\" style=\\"resize: none; overflow-y: scroll;\\"></textarea>
+ <!---->
+ <!---->
+ <!---->
+ </div>
+ </div>
+ <!---->
+ <!---->
+ </div>
+ <div class=\\"gl-display-flex gl-justify-content-start gl-py-3\\"><button data-testid=\\"integration-form-submit\\" type=\\"submit\\" class=\\"btn js-no-auto-disable btn-success btn-md gl-button\\">
+ <!---->
+ <!----> <span class=\\"gl-button-text\\">Save integration
+ </span></button> <button data-testid=\\"integration-test-and-submit\\" type=\\"button\\" class=\\"btn gl-mx-3 js-no-auto-disable btn-success btn-md gl-button btn-success-secondary\\">
+ <!---->
+ <!----> <span class=\\"gl-button-text\\">Save and test payload</span></button> <button type=\\"reset\\" class=\\"btn js-no-auto-disable btn-default btn-md gl-button\\">
+ <!---->
+ <!----> <span class=\\"gl-button-text\\">Cancel</span></button></div>
+ </div>
+</form>"
+`;
diff --git a/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap
new file mode 100644
index 00000000000..9306bf24baf
--- /dev/null
+++ b/spec/frontend/alerts_settings/__snapshots__/alerts_settings_form_old_spec.js.snap
@@ -0,0 +1,47 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AlertsSettingsFormOld with default values renders the initial template 1`] = `
+"<gl-form-stub>
+ <h5 class=\\"gl-font-lg gl-my-5\\"></h5>
+ <!---->
+ <div data-testid=\\"alert-settings-description\\">
+ <p>
+ <gl-sprintf-stub message=\\"You must provide this URL and authorization key to authorize an external service to send alerts to GitLab. You can provide this URL and key to multiple services. After configuring an external service, alerts from your service will display on the GitLab %{linkStart}Alerts%{linkEnd} page.\\"></gl-sprintf-stub>
+ </p>
+ <p>
+ <gl-sprintf-stub message=\\"Review your external service's documentation to learn where to provide this information to your external service, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.\\"></gl-sprintf-stub>
+ </p>
+ </div>
+ <gl-form-group-stub label-for=\\"integration-type\\" label=\\"Integration\\">
+ <gl-form-select-stub id=\\"integration-type\\" options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"HTTP\\"></gl-form-select-stub> <span class=\\"gl-text-gray-500\\"><gl-sprintf-stub message=\\"Learn more about our our upcoming %{linkStart}integrations%{linkEnd}\\"></gl-sprintf-stub></span>
+ </gl-form-group-stub>
+ <gl-form-group-stub label=\\"Active\\" label-for=\\"active\\">
+ <toggle-button-stub id=\\"active\\"></toggle-button-stub>
+ </gl-form-group-stub>
+ <!---->
+ <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\">
+ <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-500\\">
+
+ </span>
+ </gl-form-group-stub>
+ <gl-form-group-stub label-for=\\"authorization-key\\">
+ <gl-form-input-group-stub value=\\"\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub>
+ <gl-modal-stub modalid=\\"tokenModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\">
+ Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.
+ </gl-modal-stub>
+ </gl-form-group-stub>
+ <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\">
+ <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub>
+ </gl-form-group-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub>
+ <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\">
+ <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
+ Save changes
+ </gl-button-stub>
+ <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" buttontextclasses=\\"\\" disabled=\\"true\\">
+ Cancel
+ </gl-button-stub>
+ </div>
+</gl-form-stub>"
+`;
diff --git a/spec/frontend/alerts_settings/alert_mapping_builder_spec.js b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js
new file mode 100644
index 00000000000..12536c27dfe
--- /dev/null
+++ b/spec/frontend/alerts_settings/alert_mapping_builder_spec.js
@@ -0,0 +1,97 @@
+import { GlIcon, GlFormInput, GlDropdown, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import AlertMappingBuilder, { i18n } from '~/alerts_settings/components/alert_mapping_builder.vue';
+import gitlabFields from '~/alerts_settings/components/mocks/gitlabFields.json';
+import parsedMapping from '~/alerts_settings/components/mocks/parsedMapping.json';
+
+describe('AlertMappingBuilder', () => {
+ let wrapper;
+
+ function mountComponent() {
+ wrapper = shallowMount(AlertMappingBuilder, {
+ propsData: {
+ payloadFields: parsedMapping.samplePayload.payloadAlerFields.nodes,
+ mapping: parsedMapping.storedMapping.nodes,
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ const findColumnInRow = (row, column) =>
+ wrapper
+ .findAll('.gl-display-table-row')
+ .at(row)
+ .findAll('.gl-display-table-cell ')
+ .at(column);
+
+ it('renders column captions', () => {
+ expect(findColumnInRow(0, 0).text()).toContain(i18n.columns.gitlabKeyTitle);
+ expect(findColumnInRow(0, 2).text()).toContain(i18n.columns.payloadKeyTitle);
+ expect(findColumnInRow(0, 3).text()).toContain(i18n.columns.fallbackKeyTitle);
+
+ const fallbackColumnIcon = findColumnInRow(0, 3).find(GlIcon);
+ expect(fallbackColumnIcon.exists()).toBe(true);
+ expect(fallbackColumnIcon.attributes('name')).toBe('question');
+ expect(fallbackColumnIcon.attributes('title')).toBe(i18n.fallbackTooltip);
+ });
+
+ it('renders disabled form input for each mapped field', () => {
+ gitlabFields.forEach((field, index) => {
+ const input = findColumnInRow(index + 1, 0).find(GlFormInput);
+ expect(input.attributes('value')).toBe(`${field.label} (${field.type.join(' or ')})`);
+ expect(input.attributes('disabled')).toBe('');
+ });
+ });
+
+ it('renders right arrow next to each input', () => {
+ gitlabFields.forEach((field, index) => {
+ const arrow = findColumnInRow(index + 1, 1).find('.right-arrow');
+ expect(arrow.exists()).toBe(true);
+ });
+ });
+
+ it('renders mapping dropdown for each field', () => {
+ gitlabFields.forEach(({ compatibleTypes }, index) => {
+ const dropdown = findColumnInRow(index + 1, 2).find(GlDropdown);
+ const searchBox = dropdown.find(GlSearchBoxByType);
+ const dropdownItems = dropdown.findAll(GlDropdownItem);
+ const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
+ const numberOfMappingOptions = nodes.filter(({ type }) =>
+ type.some(t => compatibleTypes.includes(t)),
+ );
+
+ expect(dropdown.exists()).toBe(true);
+ expect(searchBox.exists()).toBe(true);
+ expect(dropdownItems).toHaveLength(numberOfMappingOptions.length);
+ });
+ });
+
+ it('renders fallback dropdown only for the fields that have fallback', () => {
+ gitlabFields.forEach(({ compatibleTypes, numberOfFallbacks }, index) => {
+ const dropdown = findColumnInRow(index + 1, 3).find(GlDropdown);
+ expect(dropdown.exists()).toBe(Boolean(numberOfFallbacks));
+
+ if (numberOfFallbacks) {
+ const searchBox = dropdown.find(GlSearchBoxByType);
+ const dropdownItems = dropdown.findAll(GlDropdownItem);
+ const { nodes } = parsedMapping.samplePayload.payloadAlerFields;
+ const numberOfMappingOptions = nodes.filter(({ type }) =>
+ type.some(t => compatibleTypes.includes(t)),
+ );
+
+ expect(searchBox.exists()).toBe(Boolean(numberOfFallbacks));
+ expect(dropdownItems).toHaveLength(numberOfMappingOptions.length);
+ }
+ });
+ });
+});
diff --git a/spec/frontend/alert_settings/alerts_integrations_list_spec.js b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js
index 6fc9901db2a..90bb38f0c2b 100644
--- a/spec/frontend/alert_settings/alerts_integrations_list_spec.js
+++ b/spec/frontend/alerts_settings/alerts_integrations_list_spec.js
@@ -1,19 +1,22 @@
-import { GlTable, GlIcon } from '@gitlab/ui';
+import { GlTable, GlIcon, GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
+import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import Tracking from '~/tracking';
import AlertIntegrationsList, {
i18n,
} from '~/alerts_settings/components/alerts_integrations_list.vue';
-import { trackAlertIntergrationsViewsOptions } from '~/alerts_settings/constants';
+import { trackAlertIntegrationsViewsOptions } from '~/alerts_settings/constants';
const mockIntegrations = [
{
- activated: true,
+ id: '1',
+ active: true,
name: 'Integration 1',
type: 'HTTP endpoint',
},
{
- activated: false,
+ id: '2',
+ active: false,
name: 'Integration 2',
type: 'HTTP endpoint',
},
@@ -21,15 +24,23 @@ const mockIntegrations = [
describe('AlertIntegrationsList', () => {
let wrapper;
+ const { trigger: triggerIntersection } = useMockIntersectionObserver();
- function mountComponent(propsData = {}) {
+ function mountComponent({ data = {}, props = {} } = {}) {
wrapper = mount(AlertIntegrationsList, {
+ data() {
+ return { ...data };
+ },
propsData: {
integrations: mockIntegrations,
- ...propsData,
+ ...props,
+ },
+ provide: {
+ glFeatures: { httpIntegrationsList: true },
},
stubs: {
GlIcon: true,
+ GlButton: true,
},
});
}
@@ -46,6 +57,7 @@ describe('AlertIntegrationsList', () => {
});
const findTableComponent = () => wrapper.find(GlTable);
+ const findTableComponentRows = () => wrapper.find(GlTable).findAll('table tbody tr');
const finsStatusCell = () => wrapper.findAll('[data-testid="integration-activated-status"]');
it('renders a table', () => {
@@ -53,10 +65,23 @@ describe('AlertIntegrationsList', () => {
});
it('renders an empty state when no integrations provided', () => {
- mountComponent({ integrations: [] });
+ mountComponent({ props: { integrations: [] } });
expect(findTableComponent().text()).toContain(i18n.emptyState);
});
+ it('renders an an edit and delete button for each integration', () => {
+ expect(findTableComponent().findAll(GlButton).length).toBe(4);
+ });
+
+ it('renders an highlighted row when a current integration is selected to edit', () => {
+ mountComponent({ data: { currentIntegration: { id: '1' } } });
+ expect(
+ findTableComponentRows()
+ .at(0)
+ .classes(),
+ ).toContain('gl-bg-blue-50');
+ });
+
describe('integration status', () => {
it('enabled', () => {
const cell = finsStatusCell().at(0);
@@ -77,12 +102,23 @@ describe('AlertIntegrationsList', () => {
describe('Snowplow tracking', () => {
beforeEach(() => {
- jest.spyOn(Tracking, 'event');
mountComponent();
+ jest.spyOn(Tracking, 'event');
+ });
+
+ it('should NOT track alert list page views when list is collapsed', () => {
+ triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: false } });
+
+ expect(Tracking.event).not.toHaveBeenCalled();
});
- it('should track alert list page views', () => {
- const { category, action } = trackAlertIntergrationsViewsOptions;
+ it('should track alert list page views only once when list is expanded', () => {
+ triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } });
+ triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } });
+ triggerIntersection(wrapper.vm.$el, { entry: { isIntersecting: true } });
+
+ const { category, action } = trackAlertIntegrationsViewsOptions;
+ expect(Tracking.event).toHaveBeenCalledTimes(1);
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
diff --git a/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js
new file mode 100644
index 00000000000..fbd482b1906
--- /dev/null
+++ b/spec/frontend/alerts_settings/alerts_settings_form_new_spec.js
@@ -0,0 +1,364 @@
+import { mount } from '@vue/test-utils';
+import {
+ GlForm,
+ GlFormSelect,
+ GlCollapse,
+ GlFormInput,
+ GlToggle,
+ GlFormTextarea,
+} from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_new.vue';
+import { defaultAlertSettingsConfig } from './util';
+import { typeSet } from '~/alerts_settings/constants';
+
+describe('AlertsSettingsFormNew', () => {
+ let wrapper;
+ const mockToastShow = jest.fn();
+
+ const createComponent = ({
+ data = {},
+ props = {},
+ multipleHttpIntegrationsCustomMapping = false,
+ } = {}) => {
+ wrapper = mount(AlertsSettingsForm, {
+ data() {
+ return { ...data };
+ },
+ propsData: {
+ loading: false,
+ canAddIntegration: true,
+ canManageOpsgenie: true,
+ ...props,
+ },
+ provide: {
+ glFeatures: { multipleHttpIntegrationsCustomMapping },
+ ...defaultAlertSettingsConfig,
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
+ },
+ });
+ };
+
+ const findForm = () => wrapper.find(GlForm);
+ const findSelect = () => wrapper.find(GlFormSelect);
+ const findFormSteps = () => wrapper.find(GlCollapse);
+ const findFormFields = () => wrapper.findAll(GlFormInput);
+ const findFormToggle = () => wrapper.find(GlToggle);
+ const findTestPayloadSection = () => wrapper.find(`[id = "test-integration"]`);
+ const findMappingBuilderSection = () => wrapper.find(`[id = "mapping-builder"]`);
+ const findSubmitButton = () => wrapper.find(`[type = "submit"]`);
+ const findMultiSupportText = () =>
+ wrapper.find(`[data-testid="multi-integrations-not-supported"]`);
+ const findJsonTestSubmit = () => wrapper.find(`[data-testid="integration-test-and-submit"]`);
+ const findJsonTextArea = () => wrapper.find(`[id = "test-payload"]`);
+ const findActionBtn = () => wrapper.find(`[data-testid="payload-action-btn"]`);
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('with default values', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the initial template', () => {
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ it('render the initial form with only an integration type dropdown', () => {
+ expect(findForm().exists()).toBe(true);
+ expect(findSelect().exists()).toBe(true);
+ expect(findMultiSupportText().exists()).toBe(false);
+ expect(findFormSteps().attributes('visible')).toBeUndefined();
+ });
+
+ it('shows the rest of the form when the dropdown is used', async () => {
+ const options = findSelect().findAll('option');
+ await options.at(1).setSelected();
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ findFormFields()
+ .at(0)
+ .isVisible(),
+ ).toBe(true);
+ });
+
+ it('disabled the dropdown and shows help text when multi integrations are not supported', async () => {
+ createComponent({ props: { canAddIntegration: false } });
+ expect(findSelect().attributes('disabled')).toBe('disabled');
+ expect(findMultiSupportText().exists()).toBe(true);
+ });
+ });
+
+ describe('submitting integration form', () => {
+ it('allows for create-new-integration with the correct form values for HTTP', async () => {
+ createComponent({});
+
+ const options = findSelect().findAll('option');
+ await options.at(1).setSelected();
+
+ await findFormFields()
+ .at(0)
+ .setValue('Test integration');
+ await findFormToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
+
+ findForm().trigger('submit');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('create-new-integration')).toBeTruthy();
+ expect(wrapper.emitted('create-new-integration')[0]).toEqual([
+ { type: typeSet.http, variables: { name: 'Test integration', active: true } },
+ ]);
+ });
+
+ it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => {
+ createComponent({});
+
+ const options = findSelect().findAll('option');
+ await options.at(2).setSelected();
+
+ await findFormFields()
+ .at(0)
+ .setValue('Test integration');
+ await findFormFields()
+ .at(1)
+ .setValue('https://test.com');
+ await findFormToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
+
+ findForm().trigger('submit');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('create-new-integration')).toBeTruthy();
+ expect(wrapper.emitted('create-new-integration')[0]).toEqual([
+ { type: typeSet.prometheus, variables: { apiUrl: 'https://test.com', active: true } },
+ ]);
+ });
+
+ it('allows for update-integration with the correct form values for HTTP', async () => {
+ createComponent({
+ data: {
+ selectedIntegration: typeSet.http,
+ currentIntegration: { id: '1', name: 'Test integration pre' },
+ },
+ props: {
+ loading: false,
+ },
+ });
+
+ await findFormFields()
+ .at(0)
+ .setValue('Test integration post');
+ await findFormToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
+
+ findForm().trigger('submit');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('update-integration')).toBeTruthy();
+ expect(wrapper.emitted('update-integration')[0]).toEqual([
+ { type: typeSet.http, variables: { name: 'Test integration post', active: true } },
+ ]);
+ });
+
+ it('allows for update-integration with the correct form values for PROMETHEUS', async () => {
+ createComponent({
+ data: {
+ selectedIntegration: typeSet.prometheus,
+ currentIntegration: { id: '1', apiUrl: 'https://test-pre.com' },
+ },
+ props: {
+ loading: false,
+ },
+ });
+
+ await findFormFields()
+ .at(0)
+ .setValue('Test integration');
+ await findFormFields()
+ .at(1)
+ .setValue('https://test-post.com');
+ await findFormToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findSubmitButton().exists()).toBe(true);
+ expect(findSubmitButton().text()).toBe('Save integration');
+
+ findForm().trigger('submit');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.emitted('update-integration')).toBeTruthy();
+ expect(wrapper.emitted('update-integration')[0]).toEqual([
+ { type: typeSet.prometheus, variables: { apiUrl: 'https://test-post.com', active: true } },
+ ]);
+ });
+ });
+
+ describe('submitting the integration with a JSON test payload', () => {
+ beforeEach(() => {
+ createComponent({
+ data: {
+ selectedIntegration: typeSet.http,
+ currentIntegration: { id: '1', name: 'Test' },
+ active: true,
+ },
+ props: {
+ loading: false,
+ },
+ });
+ });
+
+ it('should not allow a user to test invalid JSON', async () => {
+ jest.useFakeTimers();
+ await findJsonTextArea().setValue('Invalid JSON');
+
+ jest.runAllTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(findJsonTestSubmit().exists()).toBe(true);
+ expect(findJsonTestSubmit().text()).toBe('Save and test payload');
+ expect(findJsonTestSubmit().props('disabled')).toBe(true);
+ });
+
+ it('should allow for the form to be automatically saved if the test payload is successfully submitted', async () => {
+ jest.useFakeTimers();
+ await findJsonTextArea().setValue('{ "value": "value" }');
+
+ jest.runAllTimers();
+ await wrapper.vm.$nextTick();
+ expect(findJsonTestSubmit().props('disabled')).toBe(false);
+ });
+ });
+
+ describe('Test payload section for HTTP integration', () => {
+ beforeEach(() => {
+ createComponent({
+ multipleHttpIntegrationsCustomMapping: true,
+ props: {
+ currentIntegration: {
+ type: typeSet.http,
+ },
+ },
+ });
+ });
+
+ describe.each`
+ active | resetSamplePayloadConfirmed | disabled
+ ${true} | ${true} | ${undefined}
+ ${false} | ${true} | ${'disabled'}
+ ${true} | ${false} | ${'disabled'}
+ ${false} | ${false} | ${'disabled'}
+ `('', ({ active, resetSamplePayloadConfirmed, disabled }) => {
+ const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
+ const enabledState = disabled === 'disabled' ? 'disabled' : 'enabled';
+ const activeState = active ? 'active' : 'not active';
+
+ it(`textarea should be ${enabledState} when payload reset ${payloadResetMsg} and current integration is ${activeState}`, async () => {
+ wrapper.setData({
+ customMapping: { samplePayload: true },
+ active,
+ resetSamplePayloadConfirmed,
+ });
+ await wrapper.vm.$nextTick();
+ expect(
+ findTestPayloadSection()
+ .find(GlFormTextarea)
+ .attributes('disabled'),
+ ).toBe(disabled);
+ });
+ });
+
+ describe('action buttons for sample payload', () => {
+ describe.each`
+ resetSamplePayloadConfirmed | samplePayload | caption
+ ${false} | ${true} | ${'Edit payload'}
+ ${true} | ${false} | ${'Submit payload'}
+ ${true} | ${true} | ${'Submit payload'}
+ ${false} | ${false} | ${'Submit payload'}
+ `('', ({ resetSamplePayloadConfirmed, samplePayload, caption }) => {
+ const samplePayloadMsg = samplePayload ? 'was provided' : 'was not provided';
+ const payloadResetMsg = resetSamplePayloadConfirmed ? 'was confirmed' : 'was not confirmed';
+
+ it(`shows ${caption} button when sample payload ${samplePayloadMsg} and payload reset ${payloadResetMsg}`, async () => {
+ wrapper.setData({
+ selectedIntegration: typeSet.http,
+ customMapping: { samplePayload },
+ resetSamplePayloadConfirmed,
+ });
+ await wrapper.vm.$nextTick();
+ expect(findActionBtn().text()).toBe(caption);
+ });
+ });
+ });
+
+ describe('Parsing payload', () => {
+ it('displays a toast message on successful parse', async () => {
+ jest.useFakeTimers();
+ wrapper.setData({
+ selectedIntegration: typeSet.http,
+ customMapping: { samplePayload: false },
+ });
+ await wrapper.vm.$nextTick();
+
+ findActionBtn().vm.$emit('click');
+ jest.advanceTimersByTime(1000);
+
+ await waitForPromises();
+
+ expect(mockToastShow).toHaveBeenCalledWith(
+ 'Sample payload has been parsed. You can now map the fields.',
+ );
+ });
+ });
+ });
+
+ describe('Mapping builder section', () => {
+ describe.each`
+ featureFlag | integrationOption | visible
+ ${true} | ${1} | ${true}
+ ${true} | ${2} | ${false}
+ ${false} | ${1} | ${false}
+ ${false} | ${2} | ${false}
+ `('', ({ featureFlag, integrationOption, visible }) => {
+ const visibleMsg = visible ? 'is rendered' : 'is not rendered';
+ const featureFlagMsg = featureFlag ? 'is enabled' : 'is disabled';
+ const integrationType = integrationOption === 1 ? typeSet.http : typeSet.prometheus;
+
+ it(`${visibleMsg} when multipleHttpIntegrationsCustomMapping feature flag ${featureFlagMsg} and integration type is ${integrationType}`, async () => {
+ createComponent({ multipleHttpIntegrationsCustomMapping: featureFlag });
+ const options = findSelect().findAll('option');
+ options.at(integrationOption).setSelected();
+ await wrapper.vm.$nextTick();
+ expect(findMappingBuilderSection().exists()).toBe(visible);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/alert_settings/alert_settings_form_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js
index 6e1ea31ed6a..3d0dfb44d63 100644
--- a/spec/frontend/alert_settings/alert_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/alerts_settings_form_old_spec.js
@@ -1,20 +1,14 @@
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlAlert } from '@gitlab/ui';
-import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form.vue';
-import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
+import AlertsSettingsForm from '~/alerts_settings/components/alerts_settings_form_old.vue';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import { i18n } from '~/alerts_settings/constants';
import service from '~/alerts_settings/services';
+import { defaultAlertSettingsConfig } from './util';
jest.mock('~/alerts_settings/services');
-const PROMETHEUS_URL = '/prometheus/alerts/notify.json';
-const GENERIC_URL = '/alerts/notify.json';
-const KEY = 'abcedfg123';
-const INVALID_URL = 'http://invalid';
-const ACTIVATED = false;
-
-describe('AlertsSettingsForm', () => {
+describe('AlertsSettingsFormOld', () => {
let wrapper;
const createComponent = ({ methods } = {}, data) => {
@@ -23,26 +17,7 @@ describe('AlertsSettingsForm', () => {
return { ...data };
},
provide: {
- generic: {
- authorizationKey: KEY,
- formPath: INVALID_URL,
- url: GENERIC_URL,
- alertsSetupUrl: INVALID_URL,
- alertsUsageUrl: INVALID_URL,
- activated: ACTIVATED,
- },
- prometheus: {
- authorizationKey: KEY,
- prometheusFormPath: INVALID_URL,
- prometheusUrl: PROMETHEUS_URL,
- activated: ACTIVATED,
- },
- opsgenie: {
- opsgenieMvcIsAvailable: true,
- formPath: INVALID_URL,
- activated: ACTIVATED,
- opsgenieMvcTargetUrl: GENERIC_URL,
- },
+ ...defaultAlertSettingsConfig,
},
methods,
});
@@ -63,7 +38,10 @@ describe('AlertsSettingsForm', () => {
});
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
describe('with default values', () => {
@@ -76,11 +54,6 @@ describe('AlertsSettingsForm', () => {
});
});
- it('renders alerts integrations list', () => {
- createComponent();
- expect(wrapper.find(IntegrationsList).exists()).toBe(true);
- });
-
describe('reset key', () => {
it('triggers resetKey method', () => {
const resetKey = jest.fn();
@@ -96,7 +69,7 @@ describe('AlertsSettingsForm', () => {
createComponent(
{},
{
- authKey: 'newToken',
+ token: 'newToken',
},
);
@@ -140,7 +113,7 @@ describe('AlertsSettingsForm', () => {
createComponent(
{},
{
- selectedEndpoint: 'prometheus',
+ selectedIntegration: 'PROMETHEUS',
},
);
});
@@ -154,7 +127,7 @@ describe('AlertsSettingsForm', () => {
});
it('shows the correct default API URL', () => {
- expect(findUrl().attributes('value')).toBe(PROMETHEUS_URL);
+ expect(findUrl().attributes('value')).toBe(defaultAlertSettingsConfig.prometheus.url);
});
});
@@ -163,7 +136,7 @@ describe('AlertsSettingsForm', () => {
createComponent(
{},
{
- selectedEndpoint: 'opsgenie',
+ selectedIntegration: 'OPSGENIE',
},
);
});
diff --git a/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js
new file mode 100644
index 00000000000..7384cf9a095
--- /dev/null
+++ b/spec/frontend/alerts_settings/alerts_settings_wrapper_spec.js
@@ -0,0 +1,415 @@
+import VueApollo from 'vue-apollo';
+import { mount, createLocalVue } from '@vue/test-utils';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
+import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
+import AlertsSettingsWrapper from '~/alerts_settings/components/alerts_settings_wrapper.vue';
+import AlertsSettingsFormOld from '~/alerts_settings/components/alerts_settings_form_old.vue';
+import AlertsSettingsFormNew from '~/alerts_settings/components/alerts_settings_form_new.vue';
+import IntegrationsList from '~/alerts_settings/components/alerts_integrations_list.vue';
+import getIntegrationsQuery from '~/alerts_settings/graphql/queries/get_integrations.query.graphql';
+import createHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/create_http_integration.mutation.graphql';
+import createPrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/create_prometheus_integration.mutation.graphql';
+import updateHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/update_http_integration.mutation.graphql';
+import updatePrometheusIntegrationMutation from '~/alerts_settings/graphql/mutations/update_prometheus_integration.mutation.graphql';
+import destroyHttpIntegrationMutation from '~/alerts_settings/graphql/mutations/destroy_http_integration.mutation.graphql';
+import resetHttpTokenMutation from '~/alerts_settings/graphql/mutations/reset_http_token.mutation.graphql';
+import resetPrometheusTokenMutation from '~/alerts_settings/graphql/mutations/reset_prometheus_token.mutation.graphql';
+import { typeSet } from '~/alerts_settings/constants';
+import {
+ ADD_INTEGRATION_ERROR,
+ RESET_INTEGRATION_TOKEN_ERROR,
+ UPDATE_INTEGRATION_ERROR,
+ INTEGRATION_PAYLOAD_TEST_ERROR,
+ DELETE_INTEGRATION_ERROR,
+} from '~/alerts_settings/utils/error_messages';
+import createFlash from '~/flash';
+import { defaultAlertSettingsConfig } from './util';
+import mockIntegrations from './mocks/integrations.json';
+import {
+ createHttpVariables,
+ updateHttpVariables,
+ createPrometheusVariables,
+ updatePrometheusVariables,
+ ID,
+ errorMsg,
+ getIntegrationsQueryResponse,
+ destroyIntegrationResponse,
+ integrationToDestroy,
+ destroyIntegrationResponseWithErrors,
+} from './mocks/apollo_mock';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+
+describe('AlertsSettingsWrapper', () => {
+ let wrapper;
+ let fakeApollo;
+ let destroyIntegrationHandler;
+ useMockIntersectionObserver();
+
+ const findLoader = () => wrapper.find(IntegrationsList).find(GlLoadingIcon);
+ const findIntegrations = () => wrapper.find(IntegrationsList).findAll('table tbody tr');
+
+ async function destroyHttpIntegration(localWrapper) {
+ await jest.runOnlyPendingTimers();
+ await localWrapper.vm.$nextTick();
+
+ localWrapper
+ .find(IntegrationsList)
+ .vm.$emit('delete-integration', { id: integrationToDestroy.id });
+ }
+
+ async function awaitApolloDomMock() {
+ await wrapper.vm.$nextTick(); // kick off the DOM update
+ await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
+ await wrapper.vm.$nextTick(); // kick off the DOM update for flash
+ }
+
+ const createComponent = ({ data = {}, provide = {}, loading = false } = {}) => {
+ wrapper = mount(AlertsSettingsWrapper, {
+ data() {
+ return { ...data };
+ },
+ provide: {
+ ...defaultAlertSettingsConfig,
+ glFeatures: { httpIntegrationsList: false },
+ ...provide,
+ },
+ mocks: {
+ $apollo: {
+ mutate: jest.fn(),
+ query: jest.fn(),
+ queries: {
+ integrations: {
+ loading,
+ },
+ },
+ },
+ },
+ });
+ };
+
+ function createComponentWithApollo({
+ destroyHandler = jest.fn().mockResolvedValue(destroyIntegrationResponse),
+ } = {}) {
+ localVue.use(VueApollo);
+ destroyIntegrationHandler = destroyHandler;
+
+ const requestHandlers = [
+ [getIntegrationsQuery, jest.fn().mockResolvedValue(getIntegrationsQueryResponse)],
+ [destroyHttpIntegrationMutation, destroyIntegrationHandler],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ wrapper = mount(AlertsSettingsWrapper, {
+ localVue,
+ apolloProvider: fakeApollo,
+ provide: {
+ ...defaultAlertSettingsConfig,
+ glFeatures: { httpIntegrationsList: true },
+ },
+ });
+ }
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('with httpIntegrationsList feature flag disabled', () => {
+ it('renders data driven alerts integrations list and old form by default', () => {
+ createComponent();
+ expect(wrapper.find(IntegrationsList).exists()).toBe(true);
+ expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(true);
+ expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(false);
+ });
+ });
+
+ describe('with httpIntegrationsList feature flag enabled', () => {
+ it('renders the GraphQL alerts integrations list and new form', () => {
+ createComponent({ provide: { glFeatures: { httpIntegrationsList: true } } });
+ expect(wrapper.find(IntegrationsList).exists()).toBe(true);
+ expect(wrapper.find(AlertsSettingsFormOld).exists()).toBe(false);
+ expect(wrapper.find(AlertsSettingsFormNew).exists()).toBe(true);
+ });
+
+ it('uses a loading state inside the IntegrationsList table', () => {
+ createComponent({
+ data: { integrations: {} },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: true,
+ });
+ expect(wrapper.find(IntegrationsList).exists()).toBe(true);
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('renders the IntegrationsList table using the API data', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+ expect(findLoader().exists()).toBe(false);
+ expect(findIntegrations()).toHaveLength(mockIntegrations.length);
+ });
+
+ it('calls `$apollo.mutate` with `createHttpIntegrationMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { createHttpIntegrationMutation: { integration: { id: '1' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
+ type: typeSet.http,
+ variables: createHttpVariables,
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: createHttpIntegrationMutation,
+ update: expect.anything(),
+ variables: createHttpVariables,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updateHttpIntegrationMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { updateHttpIntegrationMutation: { integration: { id: '1' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {
+ type: typeSet.http,
+ variables: updateHttpVariables,
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updateHttpIntegrationMutation,
+ variables: updateHttpVariables,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `resetHttpTokenMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { resetHttpTokenMutation: { integration: { id: '1' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {
+ type: typeSet.http,
+ variables: { id: ID },
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: resetHttpTokenMutation,
+ variables: {
+ id: ID,
+ },
+ });
+ });
+
+ it('calls `$apollo.mutate` with `createPrometheusIntegrationMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { createPrometheusIntegrationMutation: { integration: { id: '2' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {
+ type: typeSet.prometheus,
+ variables: createPrometheusVariables,
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: createPrometheusIntegrationMutation,
+ update: expect.anything(),
+ variables: createPrometheusVariables,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `updatePrometheusIntegrationMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { updatePrometheusIntegrationMutation: { integration: { id: '2' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {
+ type: typeSet.prometheus,
+ variables: updatePrometheusVariables,
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: updatePrometheusIntegrationMutation,
+ variables: updatePrometheusVariables,
+ });
+ });
+
+ it('calls `$apollo.mutate` with `resetPrometheusTokenMutation`', () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue({
+ data: { resetPrometheusTokenMutation: { integration: { id: '1' } } },
+ });
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {
+ type: typeSet.prometheus,
+ variables: { id: ID },
+ });
+
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: resetPrometheusTokenMutation,
+ variables: {
+ id: ID,
+ },
+ });
+ });
+
+ it('shows an error alert when integration creation fails ', async () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(ADD_INTEGRATION_ERROR);
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('create-new-integration', {});
+
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: ADD_INTEGRATION_ERROR });
+ });
+
+ it('shows an error alert when integration token reset fails ', async () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(RESET_INTEGRATION_TOKEN_ERROR);
+
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('reset-token', {});
+
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({ message: RESET_INTEGRATION_TOKEN_ERROR });
+ });
+
+ it('shows an error alert when integration update fails ', async () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg);
+
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('update-integration', {});
+
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({ message: UPDATE_INTEGRATION_ERROR });
+ });
+
+ it('shows an error alert when integration test payload fails ', async () => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true } },
+ loading: false,
+ });
+
+ wrapper.find(AlertsSettingsFormNew).vm.$emit('test-payload-failure');
+
+ await waitForPromises();
+ expect(createFlash).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR });
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('with mocked Apollo client', () => {
+ it('has a selection of integrations loaded via the getIntegrationsQuery', async () => {
+ createComponentWithApollo();
+
+ await jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(findIntegrations()).toHaveLength(4);
+ });
+
+ it('calls a mutation with correct parameters and destroys a integration', async () => {
+ createComponentWithApollo();
+
+ await destroyHttpIntegration(wrapper);
+
+ expect(destroyIntegrationHandler).toHaveBeenCalled();
+
+ await wrapper.vm.$nextTick();
+
+ expect(findIntegrations()).toHaveLength(3);
+ });
+
+ it('displays flash if mutation had a recoverable error', async () => {
+ createComponentWithApollo({
+ destroyHandler: jest.fn().mockResolvedValue(destroyIntegrationResponseWithErrors),
+ });
+
+ await destroyHttpIntegration(wrapper);
+ await awaitApolloDomMock();
+
+ expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' });
+ });
+
+ it('displays flash if mutation had a non-recoverable error', async () => {
+ createComponentWithApollo({
+ destroyHandler: jest.fn().mockRejectedValue('Error'),
+ });
+
+ await destroyHttpIntegration(wrapper);
+ await awaitApolloDomMock();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: DELETE_INTEGRATION_ERROR,
+ });
+ });
+ });
+
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ describe('Opsgenie integration', () => {
+ it.each([true, false])('it shows/hides the alert when opsgenie is %s', active => {
+ createComponent({
+ data: { integrations: { list: mockIntegrations }, currentIntegration: mockIntegrations[0] },
+ provide: { glFeatures: { httpIntegrationsList: true }, opsgenie: { active } },
+ loading: false,
+ });
+
+ expect(wrapper.find(GlAlert).exists()).toBe(active);
+ });
+ });
+});
diff --git a/spec/frontend/alerts_settings/mocks/apollo_mock.js b/spec/frontend/alerts_settings/mocks/apollo_mock.js
new file mode 100644
index 00000000000..e0eba1e8421
--- /dev/null
+++ b/spec/frontend/alerts_settings/mocks/apollo_mock.js
@@ -0,0 +1,123 @@
+const projectPath = '';
+export const ID = 'gid://gitlab/AlertManagement::HttpIntegration/7';
+export const errorMsg = 'Something went wrong';
+
+export const createHttpVariables = {
+ name: 'Test Pre',
+ active: true,
+ projectPath,
+};
+
+export const updateHttpVariables = {
+ name: 'Test Pre',
+ active: true,
+ id: ID,
+};
+
+export const createPrometheusVariables = {
+ apiUrl: 'https://test-pre.com',
+ active: true,
+ projectPath,
+};
+
+export const updatePrometheusVariables = {
+ apiUrl: 'https://test-pre.com',
+ active: true,
+ id: ID,
+};
+
+export const getIntegrationsQueryResponse = {
+ data: {
+ project: {
+ alertManagementIntegrations: {
+ nodes: [
+ {
+ id: '37',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 5',
+ url:
+ 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
+ token: '89eb01df471d990ff5162a1c640408cf',
+ apiUrl: null,
+ },
+ {
+ id: '41',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 9999',
+ url:
+ 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-9999/b78a566e1776cfc2.json',
+ token: 'f7579aa03844e07af3b1f0fca3f79f81',
+ apiUrl: null,
+ },
+ {
+ id: '40',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 6',
+ url:
+ 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-6/3e828ae28a240222.json',
+ token: '6536102a607a5dd74fcdde921f2349ee',
+ apiUrl: null,
+ },
+ {
+ id: '12',
+ type: 'PROMETHEUS',
+ active: false,
+ name: 'Prometheus',
+ url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/prometheus/alerts/notify.json',
+ token: '256f687c6225aa5d6ee50c3d68120c4c',
+ apiUrl: 'https://localhost.ieeeesassadasasa',
+ },
+ ],
+ },
+ },
+ },
+};
+
+export const integrationToDestroy = {
+ id: '37',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 5',
+ url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
+ token: '89eb01df471d990ff5162a1c640408cf',
+ apiUrl: null,
+};
+
+export const destroyIntegrationResponse = {
+ data: {
+ httpIntegrationDestroy: {
+ errors: [],
+ integration: {
+ id: '37',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 5',
+ url:
+ 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
+ token: '89eb01df471d990ff5162a1c640408cf',
+ apiUrl: null,
+ },
+ },
+ },
+};
+
+export const destroyIntegrationResponseWithErrors = {
+ data: {
+ httpIntegrationDestroy: {
+ errors: ['Houston, we have a problem'],
+ integration: {
+ id: '37',
+ type: 'HTTP',
+ active: true,
+ name: 'Test 5',
+ url:
+ 'http://127.0.0.1:3000/h5bp/html5-boilerplate/alerts/notify/test-5/d4875758e67334f3.json',
+ token: '89eb01df471d990ff5162a1c640408cf',
+ apiUrl: null,
+ },
+ },
+ },
+};
diff --git a/spec/frontend/alerts_settings/mocks/integrations.json b/spec/frontend/alerts_settings/mocks/integrations.json
new file mode 100644
index 00000000000..b1284fc55a2
--- /dev/null
+++ b/spec/frontend/alerts_settings/mocks/integrations.json
@@ -0,0 +1,38 @@
+[
+ {
+ "id": "gid://gitlab/AlertManagement::HttpIntegration/7",
+ "type": "HTTP",
+ "active": true,
+ "name": "test",
+ "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/eddd36969b2d3d6a.json",
+ "token": "7eb24af194116411ec8d66b58c6b0d2e",
+ "apiUrl": null
+ },
+ {
+ "id": "gid://gitlab/AlertManagement::HttpIntegration/6",
+ "type": "HTTP",
+ "active": false,
+ "name": "test",
+ "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/abce123.json",
+ "token": "8639e0ce06c731b00ee3e8dcdfd14fe0",
+ "apiUrl": null
+ },
+ {
+ "id": "gid://gitlab/AlertManagement::HttpIntegration/5",
+ "type": "HTTP",
+ "active": false,
+ "name": "test",
+ "url": "http://192.168.1.152:3000/root/autodevops/alerts/notify/test/bcd64c85f918a2e2.json",
+ "token": "5c8101533d970a55d5c105f8abff2192",
+ "apiUrl": null
+ },
+ {
+ "id": "gid://gitlab/PrometheusService/12",
+ "type": "PROMETHEUS",
+ "active": true,
+ "name": "Prometheus",
+ "url": "http://192.168.1.152:3000/root/autodevops/prometheus/alerts/notify.json",
+ "token": "0b18c37caa8fe980799b349916fe5ddf",
+ "apiUrl": "https://another-url-2.com"
+ }
+]
diff --git a/spec/frontend/alerts_settings/util.js b/spec/frontend/alerts_settings/util.js
new file mode 100644
index 00000000000..f9f9b69791e
--- /dev/null
+++ b/spec/frontend/alerts_settings/util.js
@@ -0,0 +1,30 @@
+const PROMETHEUS_URL = '/prometheus/alerts/notify.json';
+const GENERIC_URL = '/alerts/notify.json';
+const KEY = 'abcedfg123';
+const INVALID_URL = 'http://invalid';
+const ACTIVE = false;
+
+export const defaultAlertSettingsConfig = {
+ generic: {
+ authorizationKey: KEY,
+ formPath: INVALID_URL,
+ url: GENERIC_URL,
+ alertsSetupUrl: INVALID_URL,
+ alertsUsageUrl: INVALID_URL,
+ active: ACTIVE,
+ },
+ prometheus: {
+ authorizationKey: KEY,
+ prometheusFormPath: INVALID_URL,
+ url: PROMETHEUS_URL,
+ active: ACTIVE,
+ },
+ opsgenie: {
+ opsgenieMvcIsAvailable: true,
+ formPath: INVALID_URL,
+ active: ACTIVE,
+ opsgenieMvcTargetUrl: GENERIC_URL,
+ },
+ projectPath: '',
+ multiIntegrations: true,
+};
diff --git a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
index 2e4eaf3fc96..98eabd577ee 100644
--- a/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
+++ b/spec/frontend/analytics/instance_statistics/apollo_mock_data.js
@@ -1,30 +1,36 @@
-const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null };
+const defaultPageInfo = {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: null,
+ endCursor: null,
+};
-export function getApolloResponse(options = {}) {
- const {
- pipelinesTotal = [],
- pipelinesSucceeded = [],
- pipelinesFailed = [],
- pipelinesCanceled = [],
- pipelinesSkipped = [],
- hasNextPage = false,
- } = options;
- return {
- data: {
- pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal },
- pipelinesSucceeded: {
- pageInfo: { ...defaultPageInfo, hasNextPage },
- nodes: pipelinesSucceeded,
- },
- pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed },
- pipelinesCanceled: {
- pageInfo: { ...defaultPageInfo, hasNextPage },
- nodes: pipelinesCanceled,
- },
- pipelinesSkipped: {
- pageInfo: { ...defaultPageInfo, hasNextPage },
- nodes: pipelinesSkipped,
- },
+export const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({
+ data: {
+ [key]: {
+ pageInfo: { ...defaultPageInfo, hasNextPage },
+ nodes: data,
},
- };
-}
+ },
+});
+
+export const mockQueryResponse = ({ key, data = [], loading = false, additionalData = [] }) => {
+ const hasNextPage = Boolean(additionalData.length);
+ const response = mockApolloResponse({ hasNextPage, key, data });
+ if (loading) {
+ return jest.fn().mockReturnValue(new Promise(() => {}));
+ }
+ if (hasNextPage) {
+ return jest
+ .fn()
+ .mockResolvedValueOnce(response)
+ .mockResolvedValueOnce(
+ mockApolloResponse({
+ hasNextPage: false,
+ key,
+ data: additionalData,
+ }),
+ );
+ }
+ return jest.fn().mockResolvedValue(response);
+};
diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap
new file mode 100644
index 00000000000..29bcd5f223b
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/__snapshots__/instance_statistics_count_chart_spec.js.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
+Array [
+ Object {
+ "data": Array [
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ Array [
+ "2020-08-01",
+ 5,
+ ],
+ ],
+ "name": "Mock Query",
+ },
+]
+`;
+
+exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = `
+Array [
+ Object {
+ "data": Array [
+ Array [
+ "2020-07-01",
+ 41,
+ ],
+ Array [
+ "2020-06-01",
+ 22,
+ ],
+ ],
+ "name": "Mock Query",
+ },
+]
+`;
diff --git a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap b/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap
deleted file mode 100644
index 0b3b685a9f2..00000000000
--- a/spec/frontend/analytics/instance_statistics/components/__snapshots__/pipelines_chart_spec.js.snap
+++ /dev/null
@@ -1,161 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
-Array [
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- Array [
- "2020-08-01",
- 5,
- ],
- ],
- "name": "Total",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- Array [
- "2020-08-01",
- 5,
- ],
- ],
- "name": "Succeeded",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 22,
- ],
- Array [
- "2020-07-01",
- 41,
- ],
- Array [
- "2020-08-01",
- 5,
- ],
- ],
- "name": "Failed",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- Array [
- "2020-08-01",
- 5,
- ],
- ],
- "name": "Canceled",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- Array [
- "2020-08-01",
- 5,
- ],
- ],
- "name": "Skipped",
- },
-]
-`;
-
-exports[`PipelinesChart with data passes the data to the line chart 1`] = `
-Array [
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 22,
- ],
- Array [
- "2020-07-01",
- 41,
- ],
- ],
- "name": "Total",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- ],
- "name": "Succeeded",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 21,
- ],
- Array [
- "2020-07-01",
- 10,
- ],
- ],
- "name": "Failed",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 22,
- ],
- Array [
- "2020-07-01",
- 41,
- ],
- ],
- "name": "Canceled",
- },
- Object {
- "data": Array [
- Array [
- "2020-06-01",
- 22,
- ],
- Array [
- "2020-07-01",
- 41,
- ],
- ],
- "name": "Skipped",
- },
-]
-`;
diff --git a/spec/frontend/analytics/instance_statistics/components/app_spec.js b/spec/frontend/analytics/instance_statistics/components/app_spec.js
index df13c9f82a9..8ac663b3046 100644
--- a/spec/frontend/analytics/instance_statistics/components/app_spec.js
+++ b/spec/frontend/analytics/instance_statistics/components/app_spec.js
@@ -1,8 +1,9 @@
import { shallowMount } from '@vue/test-utils';
import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
-import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
+import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
+import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
describe('InstanceStatisticsApp', () => {
let wrapper;
@@ -24,11 +25,21 @@ describe('InstanceStatisticsApp', () => {
expect(wrapper.find(InstanceCounts).exists()).toBe(true);
});
- it('displays the pipelines chart component', () => {
- expect(wrapper.find(PipelinesChart).exists()).toBe(true);
+ ['Pipelines', 'Issues & Merge Requests'].forEach(instance => {
+ it(`displays the ${instance} chart`, () => {
+ const chartTitles = wrapper
+ .findAll(InstanceStatisticsCountChart)
+ .wrappers.map(chartComponent => chartComponent.props('chartTitle'));
+
+ expect(chartTitles).toContain(instance);
+ });
});
it('displays the users chart component', () => {
expect(wrapper.find(UsersChart).exists()).toBe(true);
});
+
+ it('displays the projects and groups chart component', () => {
+ expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true);
+ });
});
diff --git a/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js
new file mode 100644
index 00000000000..275a84988f8
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/instance_statistics_count_chart_spec.js
@@ -0,0 +1,177 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlLineChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
+import statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import { mockCountsData1 } from '../mock_data';
+import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const loadChartErrorMessage = 'My load error message';
+const noDataMessage = 'My no data message';
+
+const queryResponseDataKey = 'instanceStatisticsMeasurements';
+const identifier = 'MOCK_QUERY';
+const mockQueryConfig = {
+ identifier,
+ title: 'Mock Query',
+ query: statsQuery,
+ loadError: 'Failed to load mock query data',
+};
+
+const mockChartConfig = {
+ loadChartErrorMessage,
+ noDataMessage,
+ chartTitle: 'Foo',
+ yAxisTitle: 'Bar',
+ xAxisTitle: 'Baz',
+ queries: [mockQueryConfig],
+};
+
+describe('InstanceStatisticsCountChart', () => {
+ let wrapper;
+ let queryHandler;
+
+ const createComponent = ({ responseHandler }) => {
+ return shallowMount(InstanceStatisticsCountChart, {
+ localVue,
+ apolloProvider: createMockApollo([[statsQuery, responseHandler]]),
+ propsData: { ...mockChartConfig },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findLoader = () => wrapper.find(ChartSkeletonLoader);
+ const findChart = () => wrapper.find(GlLineChart);
+ const findAlert = () => wrapper.find(GlAlert);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ queryHandler = mockQueryResponse({ key: queryResponseDataKey, loading: true });
+ wrapper = createComponent({ responseHandler: queryHandler });
+ });
+
+ it('requests data', () => {
+ expect(queryHandler).toBeCalledTimes(1);
+ });
+
+ it('displays the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+
+ it('does not show an error', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('without data', () => {
+ beforeEach(() => {
+ queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: [] });
+ wrapper = createComponent({ responseHandler: queryHandler });
+ });
+
+ it('renders an no data message', () => {
+ expect(findAlert().text()).toBe(noDataMessage);
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(() => {
+ queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: mockCountsData1 });
+ wrapper = createComponent({ responseHandler: queryHandler });
+ });
+
+ it('requests data', () => {
+ expect(queryHandler).toBeCalledTimes(1);
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(true);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toMatchSnapshot();
+ });
+
+ it('does not show an error', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when fetching more data', () => {
+ const recordedAt = '2020-08-01';
+ describe('when the fetchMore query returns data', () => {
+ beforeEach(async () => {
+ const newData = [{ recordedAt, count: 5 }];
+ queryHandler = mockQueryResponse({
+ key: queryResponseDataKey,
+ data: mockCountsData1,
+ additionalData: newData,
+ });
+
+ wrapper = createComponent({ responseHandler: queryHandler });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('requests data twice', () => {
+ expect(queryHandler).toBeCalledTimes(2);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toMatchSnapshot();
+ });
+ });
+
+ describe('when the fetchMore query throws an error', () => {
+ beforeEach(async () => {
+ queryHandler = jest.fn().mockResolvedValueOnce(
+ mockApolloResponse({
+ key: queryResponseDataKey,
+ data: mockCountsData1,
+ hasNextPage: true,
+ }),
+ );
+
+ wrapper = createComponent({ responseHandler: queryHandler });
+ jest
+ .spyOn(wrapper.vm.$apollo.queries[identifier], 'fetchMore')
+ .mockImplementation(jest.fn().mockRejectedValue());
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries[identifier].fetchMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('show an error message', () => {
+ expect(findAlert().text()).toBe(loadChartErrorMessage);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js
deleted file mode 100644
index a06d66f783e..00000000000
--- a/spec/frontend/analytics/instance_statistics/components/pipelines_chart_spec.js
+++ /dev/null
@@ -1,189 +0,0 @@
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlLineChart } from '@gitlab/ui/dist/charts';
-import { GlAlert } from '@gitlab/ui';
-import VueApollo from 'vue-apollo';
-import createMockApollo from 'jest/helpers/mock_apollo_helper';
-import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
-import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql';
-import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
-import { mockCountsData1, mockCountsData2 } from '../mock_data';
-import { getApolloResponse } from '../apollo_mock_data';
-
-const localVue = createLocalVue();
-localVue.use(VueApollo);
-
-describe('PipelinesChart', () => {
- let wrapper;
- let queryHandler;
-
- const createApolloProvider = pipelineStatsHandler => {
- return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]);
- };
-
- const createComponent = apolloProvider => {
- return shallowMount(PipelinesChart, {
- localVue,
- apolloProvider,
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findLoader = () => wrapper.find(ChartSkeletonLoader);
- const findChart = () => wrapper.find(GlLineChart);
- const findAlert = () => wrapper.find(GlAlert);
-
- describe('while loading', () => {
- beforeEach(() => {
- queryHandler = jest.fn().mockReturnValue(new Promise(() => {}));
- const apolloProvider = createApolloProvider(queryHandler);
- wrapper = createComponent(apolloProvider);
- });
-
- it('requests data', () => {
- expect(queryHandler).toBeCalledTimes(1);
- });
-
- it('displays the skeleton loader', () => {
- expect(findLoader().exists()).toBe(true);
- });
-
- it('hides the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
-
- it('does not show an error', () => {
- expect(findAlert().exists()).toBe(false);
- });
- });
-
- describe('without data', () => {
- beforeEach(() => {
- const emptyResponse = getApolloResponse();
- queryHandler = jest.fn().mockResolvedValue(emptyResponse);
- const apolloProvider = createApolloProvider(queryHandler);
- wrapper = createComponent(apolloProvider);
- });
-
- it('renders an no data message', () => {
- expect(findAlert().text()).toBe('There is no data available.');
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(false);
- });
- });
-
- describe('with data', () => {
- beforeEach(() => {
- const response = getApolloResponse({
- pipelinesTotal: mockCountsData1,
- pipelinesSucceeded: mockCountsData2,
- pipelinesFailed: mockCountsData2,
- pipelinesCanceled: mockCountsData1,
- pipelinesSkipped: mockCountsData1,
- });
- queryHandler = jest.fn().mockResolvedValue(response);
- const apolloProvider = createApolloProvider(queryHandler);
- wrapper = createComponent(apolloProvider);
- });
-
- it('requests data', () => {
- expect(queryHandler).toBeCalledTimes(1);
- });
-
- it('hides the skeleton loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('renders the chart', () => {
- expect(findChart().exists()).toBe(true);
- });
-
- it('passes the data to the line chart', () => {
- expect(findChart().props('data')).toMatchSnapshot();
- });
-
- it('does not show an error', () => {
- expect(findAlert().exists()).toBe(false);
- });
- });
-
- describe('when fetching more data', () => {
- const recordedAt = '2020-08-01';
- describe('when the fetchMore query returns data', () => {
- beforeEach(async () => {
- const newData = { recordedAt, count: 5 };
- const firstResponse = getApolloResponse({
- pipelinesTotal: mockCountsData2,
- pipelinesSucceeded: mockCountsData2,
- pipelinesFailed: mockCountsData1,
- pipelinesCanceled: mockCountsData2,
- pipelinesSkipped: mockCountsData2,
- hasNextPage: true,
- });
- const secondResponse = getApolloResponse({
- pipelinesTotal: [newData],
- pipelinesSucceeded: [newData],
- pipelinesFailed: [newData],
- pipelinesCanceled: [newData],
- pipelinesSkipped: [newData],
- hasNextPage: false,
- });
- queryHandler = jest
- .fn()
- .mockResolvedValueOnce(firstResponse)
- .mockResolvedValueOnce(secondResponse);
- const apolloProvider = createApolloProvider(queryHandler);
- wrapper = createComponent(apolloProvider);
-
- await wrapper.vm.$nextTick();
- });
-
- it('requests data twice', () => {
- expect(queryHandler).toBeCalledTimes(2);
- });
-
- it('passes the data to the line chart', () => {
- expect(findChart().props('data')).toMatchSnapshot();
- });
- });
-
- describe('when the fetchMore query throws an error', () => {
- beforeEach(async () => {
- const response = getApolloResponse({
- pipelinesTotal: mockCountsData2,
- pipelinesSucceeded: mockCountsData2,
- pipelinesFailed: mockCountsData1,
- pipelinesCanceled: mockCountsData2,
- pipelinesSkipped: mockCountsData2,
- hasNextPage: true,
- });
- queryHandler = jest.fn().mockResolvedValue(response);
- const apolloProvider = createApolloProvider(queryHandler);
- wrapper = createComponent(apolloProvider);
- jest
- .spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore')
- .mockImplementation(jest.fn().mockRejectedValue());
- await wrapper.vm.$nextTick();
- });
-
- it('calls fetchMore', () => {
- expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1);
- });
-
- it('show an error message', () => {
- expect(findAlert().text()).toBe(
- 'Could not load the pipelines chart. Please refresh the page to try again.',
- );
- });
- });
- });
-});
diff --git a/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js
new file mode 100644
index 00000000000..d9f42430aa8
--- /dev/null
+++ b/spec/frontend/analytics/instance_statistics/components/projects_and_groups_chart_spec.js
@@ -0,0 +1,216 @@
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlLineChart } from '@gitlab/ui/dist/charts';
+import { GlAlert } from '@gitlab/ui';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import { useFakeDate } from 'helpers/fake_date';
+import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
+import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
+import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql';
+import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql';
+import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
+import { mockQueryResponse } from '../apollo_mock_data';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('ProjectsAndGroupChart', () => {
+ let wrapper;
+ let queryResponses = { projects: null, groups: null };
+ const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }];
+
+ const createComponent = ({
+ loadingError = false,
+ projects = [],
+ groups = [],
+ projectsLoading = false,
+ groupsLoading = false,
+ projectsAdditionalData = [],
+ groupsAdditionalData = [],
+ } = {}) => {
+ queryResponses = {
+ projects: mockQueryResponse({
+ key: 'projects',
+ data: projects,
+ loading: projectsLoading,
+ additionalData: projectsAdditionalData,
+ }),
+ groups: mockQueryResponse({
+ key: 'groups',
+ data: groups,
+ loading: groupsLoading,
+ additionalData: groupsAdditionalData,
+ }),
+ };
+
+ return shallowMount(ProjectsAndGroupChart, {
+ props: {
+ startDate: useFakeDate(2020, 9, 26),
+ endDate: useFakeDate(2020, 10, 1),
+ totalDataPoints: mockCountsData2.length,
+ },
+ localVue,
+ apolloProvider: createMockApollo([
+ [projectsQuery, queryResponses.projects],
+ [groupsQuery, queryResponses.groups],
+ ]),
+ data() {
+ return { loadingError };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ queryResponses = {
+ projects: null,
+ groups: null,
+ };
+ });
+
+ const findLoader = () => wrapper.find(ChartSkeletonLoader);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findChart = () => wrapper.find(GlLineChart);
+
+ describe('while loading', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ projectsLoading: true, groupsLoading: true });
+ });
+
+ it('displays the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('while loading 1 data set', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ projects: mockCountsData2,
+ groupsLoading: true,
+ });
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(true);
+ });
+ });
+
+ describe('without data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ projects: [] });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders a no data message', () => {
+ expect(findAlert().text()).toBe('No data available.');
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not render the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe('with data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ projects: mockCountsData2 });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders the chart', () => {
+ expect(findChart().exists()).toBe(true);
+ });
+
+ it('passes the data to the line chart', () => {
+ expect(findChart().props('data')).toEqual([
+ { data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' },
+ { data: [], name: 'Total groups' },
+ ]);
+ });
+ });
+
+ describe('with errors', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({ loadingError: true });
+ await wrapper.vm.$nextTick();
+ });
+
+ it('renders an error message', () => {
+ expect(findAlert().text()).toBe('No data available.');
+ });
+
+ it('hides the skeleton loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('hides the chart', () => {
+ expect(findChart().exists()).toBe(false);
+ });
+ });
+
+ describe.each`
+ metric | loadingState | newData
+ ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }}
+ ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }}
+ `('$metric - fetchMore', ({ metric, loadingState, newData }) => {
+ describe('when the fetchMore query returns data', () => {
+ beforeEach(async () => {
+ wrapper = createComponent({
+ ...loadingState,
+ ...newData,
+ });
+
+ jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('requests data twice', () => {
+ expect(queryResponses[metric]).toBeCalledTimes(2);
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when the fetchMore query throws an error', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ ...loadingState,
+ ...newData,
+ });
+
+ jest
+ .spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore')
+ .mockImplementation(jest.fn().mockRejectedValue());
+ return wrapper.vm.$nextTick();
+ });
+
+ it('calls fetchMore', () => {
+ expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders an error message', () => {
+ expect(findAlert().text()).toBe('No data available.');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
index 7509c1e6626..6ed9d203f3d 100644
--- a/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
+++ b/spec/frontend/analytics/instance_statistics/components/users_chart_spec.js
@@ -7,7 +7,12 @@ import { useFakeDate } from 'helpers/fake_date';
import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql';
-import { mockCountsData2, roundedSortedCountsMonthlyChartData2, mockPageInfo } from '../mock_data';
+import {
+ mockCountsData1,
+ mockCountsData2,
+ roundedSortedCountsMonthlyChartData2,
+} from '../mock_data';
+import { mockQueryResponse } from '../apollo_mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
@@ -16,43 +21,13 @@ describe('UsersChart', () => {
let wrapper;
let queryHandler;
- const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({
- data: {
- users: {
- pageInfo: { ...mockPageInfo, hasNextPage },
- nodes: users,
- loading,
- },
- },
- });
-
- const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => {
- const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users });
- if (loading) {
- return jest.fn().mockReturnValue(new Promise(() => {}));
- }
- if (hasNextPage) {
- return jest
- .fn()
- .mockResolvedValueOnce(apolloQueryResponse)
- .mockResolvedValueOnce(
- mockApolloResponse({
- loading,
- hasNextPage: false,
- users: [{ recordedAt: '2020-07-21', count: 5 }],
- }),
- );
- }
- return jest.fn().mockResolvedValue(apolloQueryResponse);
- };
-
const createComponent = ({
loadingError = false,
loading = false,
users = [],
- hasNextPage = false,
+ additionalData = [],
} = {}) => {
- queryHandler = mockQueryResponse({ users, loading, hasNextPage });
+ queryHandler = mockQueryResponse({ key: 'users', data: users, loading, additionalData });
return shallowMount(UsersChart, {
props: {
@@ -157,7 +132,7 @@ describe('UsersChart', () => {
beforeEach(async () => {
wrapper = createComponent({
users: mockCountsData2,
- hasNextPage: true,
+ additionalData: mockCountsData1,
});
jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore');
@@ -177,7 +152,7 @@ describe('UsersChart', () => {
beforeEach(() => {
wrapper = createComponent({
users: mockCountsData2,
- hasNextPage: true,
+ additionalData: mockCountsData1,
});
jest
diff --git a/spec/frontend/analytics/instance_statistics/mock_data.js b/spec/frontend/analytics/instance_statistics/mock_data.js
index b737db4c55f..e86e552a952 100644
--- a/spec/frontend/analytics/instance_statistics/mock_data.js
+++ b/spec/frontend/analytics/instance_statistics/mock_data.js
@@ -33,10 +33,3 @@ export const roundedSortedCountsMonthlyChartData2 = [
['2020-06-01', 21], // average of 2020-06-x items
['2020-07-01', 10], // average of 2020-07-x items
];
-
-export const mockPageInfo = {
- hasNextPage: false,
- hasPreviousPage: false,
- startCursor: null,
- endCursor: null,
-};
diff --git a/spec/frontend/analytics/instance_statistics/utils_spec.js b/spec/frontend/analytics/instance_statistics/utils_spec.js
index d480238419b..3fd89c7f740 100644
--- a/spec/frontend/analytics/instance_statistics/utils_spec.js
+++ b/spec/frontend/analytics/instance_statistics/utils_spec.js
@@ -1,7 +1,7 @@
import {
getAverageByMonth,
- extractValues,
- sortByDate,
+ getEarliestDate,
+ generateDataKeys,
} from '~/analytics/instance_statistics/utils';
import {
mockCountsData1,
@@ -44,41 +44,38 @@ describe('getAverageByMonth', () => {
});
});
-describe('extractValues', () => {
- it('extracts only requested values', () => {
- const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
- expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
+describe('getEarliestDate', () => {
+ it('returns the date of the final item in the array', () => {
+ expect(getEarliestDate(mockCountsData1)).toBe('2020-06-12');
});
- it('is able to extract multiple values', () => {
- const data = {
- fooBar: { baz: 'quis' },
- fooBaz: { baz: 'quis' },
- fooQuis: { baz: 'quis' },
- };
- expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({
- bazBar: 'quis',
- bazBaz: 'quis',
- bazQuis: 'quis',
- });
- });
-
- it('returns empty data set when keys are not found', () => {
- const data = { foo: { baz: 'quis' }, ignored: 'ignored' };
- expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({});
+ it('returns null for an empty array', () => {
+ expect(getEarliestDate([])).toBeNull();
});
- it('returns empty data when params are missing', () => {
- expect(extractValues()).toEqual({});
+ it("returns null if the array has data but `recordedAt` isn't defined", () => {
+ expect(
+ getEarliestDate(mockCountsData1.map(({ recordedAt: date, ...rest }) => ({ date, ...rest }))),
+ ).toBeNull();
});
});
-describe('sortByDate', () => {
- it('sorts the array by date', () => {
- expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse());
+describe('generateDataKeys', () => {
+ const fakeQueries = [
+ { identifier: 'from' },
+ { identifier: 'first' },
+ { identifier: 'to' },
+ { identifier: 'last' },
+ ];
+
+ const defaultValue = 'default value';
+ const res = generateDataKeys(fakeQueries, defaultValue);
+
+ it('extracts each query identifier and sets them as object keys', () => {
+ expect(Object.keys(res)).toEqual(['from', 'first', 'to', 'last']);
});
- it('does not modify the original array', () => {
- expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1);
+ it('sets every value to the `defaultValue` provided', () => {
+ expect(Object.values(res)).toEqual(Array(fakeQueries.length).fill(defaultValue));
});
});
diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js
index 9924525929b..724d33922a1 100644
--- a/spec/frontend/api_spec.js
+++ b/spec/frontend/api_spec.js
@@ -118,6 +118,24 @@ describe('Api', () => {
});
});
+ describe('container registry', () => {
+ describe('containerRegistryDetails', () => {
+ it('fetch container registry details', async () => {
+ const expectedUrl = `foo`;
+ const apiResponse = {};
+
+ jest.spyOn(axios, 'get');
+ jest.spyOn(Api, 'buildUrl').mockReturnValueOnce(expectedUrl);
+ mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse);
+
+ const { data } = await Api.containerRegistryDetails(1);
+
+ expect(data).toEqual(apiResponse);
+ expect(axios.get).toHaveBeenCalledWith(expectedUrl, {});
+ });
+ });
+ });
+
describe('group', () => {
it('fetches a group', done => {
const groupId = '123456';
@@ -535,14 +553,15 @@ describe('Api', () => {
});
describe('issueTemplate', () => {
+ const namespace = 'some namespace';
+ const project = 'some project';
+ const templateKey = ' template #%?.key ';
+ const templateType = 'template type';
+ const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent(
+ templateKey,
+ )}`;
+
it('fetches an issue template', done => {
- const namespace = 'some namespace';
- const project = 'some project';
- const templateKey = ' template #%?.key ';
- const templateType = 'template type';
- const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent(
- templateKey,
- )}`;
mock.onGet(expectedUrl).reply(httpStatus.OK, 'test');
Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => {
@@ -550,6 +569,49 @@ describe('Api', () => {
done();
});
});
+
+ describe('when an error occurs while fetching an issue template', () => {
+ it('rejects the Promise', () => {
+ mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
+
+ Api.issueTemplate(namespace, project, templateKey, templateType, () => {
+ expect(mock.history.get).toHaveLength(1);
+ });
+ });
+ });
+ });
+
+ describe('issueTemplates', () => {
+ const namespace = 'some namespace';
+ const project = 'some project';
+ const templateType = 'template type';
+ const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`;
+
+ it('fetches all templates by type', done => {
+ const expectedData = [
+ { key: 'Template1', name: 'Template 1', content: 'This is template 1!' },
+ ];
+ mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData);
+
+ Api.issueTemplates(namespace, project, templateType, (error, response) => {
+ expect(response.length).toBe(1);
+ const { key, name, content } = response[0];
+ expect(key).toBe('Template1');
+ expect(name).toBe('Template 1');
+ expect(content).toBe('This is template 1!');
+ done();
+ });
+ });
+
+ describe('when an error occurs while fetching issue templates', () => {
+ it('rejects the Promise', () => {
+ mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR);
+
+ Api.issueTemplates(namespace, project, templateType, () => {
+ expect(mock.history.get).toHaveLength(1);
+ });
+ });
+ });
});
describe('projectTemplates', () => {
@@ -692,24 +754,23 @@ describe('Api', () => {
});
describe('pipelineJobs', () => {
- it('fetches the jobs for a given pipeline', done => {
- const projectId = 123;
- const pipelineId = 456;
- const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
- const payload = [
- {
- name: 'test',
- },
- ];
- mock.onGet(expectedUrl).reply(httpStatus.OK, payload);
+ it.each([undefined, {}, { foo: true }])(
+ 'fetches the jobs for a given pipeline given %p params',
+ async params => {
+ const projectId = 123;
+ const pipelineId = 456;
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipelines/${pipelineId}/jobs`;
+ const payload = [
+ {
+ name: 'test',
+ },
+ ];
+ mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, payload);
- Api.pipelineJobs(projectId, pipelineId)
- .then(({ data }) => {
- expect(data).toEqual(payload);
- })
- .then(done)
- .catch(done.fail);
- });
+ const { data } = await Api.pipelineJobs(projectId, pipelineId, params);
+ expect(data).toEqual(payload);
+ },
+ );
});
describe('createBranch', () => {
@@ -1232,4 +1293,91 @@ describe('Api', () => {
});
});
});
+
+ describe('Feature Flag User List', () => {
+ let expectedUrl;
+ let projectId;
+ let mockUserList;
+
+ beforeEach(() => {
+ projectId = 1000;
+ expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/feature_flags_user_lists`;
+ mockUserList = {
+ name: 'mock_user_list',
+ user_xids: '1,2,3,4',
+ project_id: 1,
+ id: 1,
+ iid: 1,
+ };
+ });
+
+ describe('fetchFeatureFlagUserLists', () => {
+ it('GETs the right url', () => {
+ mock.onGet(expectedUrl).replyOnce(httpStatus.OK, []);
+
+ return Api.fetchFeatureFlagUserLists(projectId).then(({ data }) => {
+ expect(data).toEqual([]);
+ });
+ });
+ });
+
+ describe('searchFeatureFlagUserLists', () => {
+ it('GETs the right url', () => {
+ mock.onGet(expectedUrl, { params: { search: 'test' } }).replyOnce(httpStatus.OK, []);
+
+ return Api.searchFeatureFlagUserLists(projectId, 'test').then(({ data }) => {
+ expect(data).toEqual([]);
+ });
+ });
+ });
+
+ describe('createFeatureFlagUserList', () => {
+ it('POSTs data to the right url', () => {
+ const mockUserListData = {
+ name: 'mock_user_list',
+ user_xids: '1,2,3,4',
+ };
+ mock.onPost(expectedUrl, mockUserListData).replyOnce(httpStatus.OK, mockUserList);
+
+ return Api.createFeatureFlagUserList(projectId, mockUserListData).then(({ data }) => {
+ expect(data).toEqual(mockUserList);
+ });
+ });
+ });
+
+ describe('fetchFeatureFlagUserList', () => {
+ it('GETs the right url', () => {
+ mock.onGet(`${expectedUrl}/1`).replyOnce(httpStatus.OK, mockUserList);
+
+ return Api.fetchFeatureFlagUserList(projectId, 1).then(({ data }) => {
+ expect(data).toEqual(mockUserList);
+ });
+ });
+ });
+
+ describe('updateFeatureFlagUserList', () => {
+ it('PUTs the right url', () => {
+ mock
+ .onPut(`${expectedUrl}/1`)
+ .replyOnce(httpStatus.OK, { ...mockUserList, user_xids: '5' });
+
+ return Api.updateFeatureFlagUserList(projectId, {
+ ...mockUserList,
+ user_xids: '5',
+ }).then(({ data }) => {
+ expect(data).toEqual({ ...mockUserList, user_xids: '5' });
+ });
+ });
+ });
+
+ describe('deleteFeatureFlagUserList', () => {
+ it('DELETEs the right url', () => {
+ mock.onDelete(`${expectedUrl}/1`).replyOnce(httpStatus.OK, 'deleted');
+
+ return Api.deleteFeatureFlagUserList(projectId, 1).then(({ data }) => {
+ expect(data).toBe('deleted');
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js
index 7fd6a9e7b87..c6a9c911ccf 100644
--- a/spec/frontend/awards_handler_spec.js
+++ b/spec/frontend/awards_handler_spec.js
@@ -169,29 +169,6 @@ describe('AwardsHandler', () => {
});
});
- describe('::userAuthored', () => {
- it('should update tooltip to user authored title', () => {
- const $votesBlock = $('.js-awards-block').eq(0);
- const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
- $thumbsUpEmoji.attr('data-title', 'sam');
- awardsHandler.userAuthored($thumbsUpEmoji);
-
- expect($thumbsUpEmoji.data('originalTitle')).toBe(
- 'You cannot vote on your own issue, MR and note',
- );
- });
-
- it('should restore tooltip back to initial vote list', () => {
- const $votesBlock = $('.js-awards-block').eq(0);
- const $thumbsUpEmoji = $votesBlock.find('[data-name=thumbsup]').parent();
- $thumbsUpEmoji.attr('data-title', 'sam');
- awardsHandler.userAuthored($thumbsUpEmoji);
- jest.advanceTimersByTime(2801);
-
- expect($thumbsUpEmoji.data('originalTitle')).toBe('sam');
- });
- });
-
describe('::getAwardUrl', () => {
it('returns the url for request', () => {
expect(awardsHandler.getAwardUrl()).toBe('http://test.host/-/snippets/1/toggle_award_emoji');
diff --git a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
index baedbf5771a..77dcc28dd48 100644
--- a/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
+++ b/spec/frontend/behaviors/shortcuts/shortcuts_issuable_spec.js
@@ -1,20 +1,19 @@
import $ from 'jquery';
-import 'mousetrap';
+import Mousetrap from 'mousetrap';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { getSelectedFragment } from '~/lib/utils/common_utils';
-const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
-
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
getSelectedFragment: jest.fn().mockName('getSelectedFragment'),
}));
describe('ShortcutsIssuable', () => {
- const fixtureName = 'snippets/show.html';
+ const snippetShowFixtureName = 'snippets/show.html';
+ const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html';
- preloadFixtures(fixtureName);
+ preloadFixtures(snippetShowFixtureName, mrShowFixtureName);
beforeAll(done => {
initCopyAsGFM();
@@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => {
.catch(done.fail);
});
- beforeEach(() => {
- loadFixtures(fixtureName);
- $('body').append(
- `<div class="js-main-target-form">
- <textarea class="js-vue-comment-form"></textarea>
- </div>`,
- );
- document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
-
- window.shortcut = new ShortcutsIssuable(true);
- });
+ describe('replyWithSelectedText', () => {
+ const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
+
+ beforeEach(() => {
+ loadFixtures(snippetShowFixtureName);
+ $('body').append(
+ `<div class="js-main-target-form">
+ <textarea class="js-vue-comment-form"></textarea>
+ </div>`,
+ );
+ document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
+
+ window.shortcut = new ShortcutsIssuable(true);
+ });
- afterEach(() => {
- $(FORM_SELECTOR).remove();
+ afterEach(() => {
+ $(FORM_SELECTOR).remove();
- delete window.shortcut;
- });
+ delete window.shortcut;
+ });
- describe('replyWithSelectedText', () => {
// Stub getSelectedFragment to return a node with the provided HTML.
const stubSelection = (html, invalidNode) => {
getSelectedFragment.mockImplementation(() => {
@@ -319,4 +320,55 @@ describe('ShortcutsIssuable', () => {
});
});
});
+
+ describe('copyBranchName', () => {
+ let sidebarCollapsedBtn;
+ let sidebarExpandedBtn;
+
+ beforeEach(() => {
+ loadFixtures(mrShowFixtureName);
+
+ window.shortcut = new ShortcutsIssuable();
+
+ [sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll(
+ '.sidebar-source-branch button',
+ );
+
+ [sidebarCollapsedBtn, sidebarExpandedBtn].forEach(btn => jest.spyOn(btn, 'click'));
+ });
+
+ afterEach(() => {
+ delete window.shortcut;
+ });
+
+ describe('when the sidebar is expanded', () => {
+ beforeEach(() => {
+ // simulate the applied CSS styles when the
+ // sidebar is expanded
+ sidebarCollapsedBtn.style.display = 'none';
+
+ Mousetrap.trigger('b');
+ });
+
+ it('clicks the "expanded" version of the copy source branch button', () => {
+ expect(sidebarExpandedBtn.click).toHaveBeenCalled();
+ expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when the sidebar is collapsed', () => {
+ beforeEach(() => {
+ // simulate the applied CSS styles when the
+ // sidebar is collapsed
+ sidebarExpandedBtn.style.display = 'none';
+
+ Mousetrap.trigger('b');
+ });
+
+ it('clicks the "collapsed" version of the copy source branch button', () => {
+ expect(sidebarCollapsedBtn.click).toHaveBeenCalled();
+ expect(sidebarExpandedBtn.click).not.toHaveBeenCalled();
+ });
+ });
+ });
});
diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js
index 590e36b16af..e2c73a5d5d9 100644
--- a/spec/frontend/blob/components/blob_header_default_actions_spec.js
+++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js
@@ -14,8 +14,13 @@ describe('Blob Header Default Actions', () => {
let btnGroup;
let buttons;
+ const blobHash = 'foo-bar';
+
function createComponent(propsData = {}) {
wrapper = mount(BlobHeaderActions, {
+ provide: {
+ blobHash,
+ },
propsData: {
rawPath: Blob.rawPath,
...propsData,
diff --git a/spec/frontend/blob/components/blob_header_filepath_spec.js b/spec/frontend/blob/components/blob_header_filepath_spec.js
index 43057353051..067a4ae61a0 100644
--- a/spec/frontend/blob/components/blob_header_filepath_spec.js
+++ b/spec/frontend/blob/components/blob_header_filepath_spec.js
@@ -65,7 +65,7 @@ describe('Blob Header Filepath', () => {
{},
{
scopedSlots: {
- filepathPrepend: `<span>${slotContent}</span>`,
+ 'filepath-prepend': `<span>${slotContent}</span>`,
},
},
);
diff --git a/spec/frontend/blob/components/blob_header_spec.js b/spec/frontend/blob/components/blob_header_spec.js
index 01d4bf834d2..3e84347bee4 100644
--- a/spec/frontend/blob/components/blob_header_spec.js
+++ b/spec/frontend/blob/components/blob_header_spec.js
@@ -11,7 +11,11 @@ describe('Blob Header Default Actions', () => {
function createComponent(blobProps = {}, options = {}, propsData = {}, shouldMount = false) {
const method = shouldMount ? mount : shallowMount;
+ const blobHash = 'foo-bar';
wrapper = method.call(this, BlobHeader, {
+ provide: {
+ blobHash,
+ },
propsData: {
blob: { ...Blob, ...blobProps },
...propsData,
diff --git a/spec/frontend/blob/pipeline_tour_success_mock_data.js b/spec/frontend/blob/pipeline_tour_success_mock_data.js
index 9dea3969d63..dbcba469df5 100644
--- a/spec/frontend/blob/pipeline_tour_success_mock_data.js
+++ b/spec/frontend/blob/pipeline_tour_success_mock_data.js
@@ -3,6 +3,8 @@ const modalProps = {
projectMergeRequestsPath: 'some_mr_path',
commitCookie: 'some_cookie',
humanAccess: 'maintainer',
+ exampleLink: '/example',
+ codeQualityLink: '/code-quality-link',
};
export default modalProps;
diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
index a02c968c4b5..e8011558765 100644
--- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js
+++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js
@@ -75,7 +75,7 @@ describe('PipelineTourSuccessModal', () => {
});
it('renders the link for codeQualityLink', () => {
- expect(wrapper.find(GlLink).attributes('href')).toBe(wrapper.vm.$options.codeQualityLink);
+ expect(wrapper.find(GlLink).attributes('href')).toBe('/code-quality-link');
});
it('calls to remove cookie', () => {
diff --git a/spec/frontend/boards/board_list_new_spec.js b/spec/frontend/boards/board_list_new_spec.js
index 163611c2197..55516e3fd56 100644
--- a/spec/frontend/boards/board_list_new_spec.js
+++ b/spec/frontend/boards/board_list_new_spec.js
@@ -77,6 +77,8 @@ const createComponent = ({
provide: {
groupId: null,
rootPath: '/',
+ weightFeatureAvailable: false,
+ boardWeight: null,
},
});
diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js
new file mode 100644
index 00000000000..e185a6d5419
--- /dev/null
+++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js
@@ -0,0 +1,308 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import { GlDropdownItem, GlAvatarLink, GlAvatarLabeled, GlSearchBoxByType } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import VueApollo from 'vue-apollo';
+import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
+import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
+import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import store from '~/boards/stores';
+import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
+import searchUsers from '~/boards/queries/users_search.query.graphql';
+import { participants } from '../mock_data';
+
+const localVue = createLocalVue();
+
+localVue.use(VueApollo);
+
+describe('BoardCardAssigneeDropdown', () => {
+ let wrapper;
+ let fakeApollo;
+ let getIssueParticipantsSpy;
+ let getSearchUsersSpy;
+
+ const iid = '111';
+ const activeIssueName = 'test';
+ const anotherIssueName = 'hello';
+
+ const createComponent = (search = '') => {
+ wrapper = mount(BoardAssigneeDropdown, {
+ data() {
+ return {
+ search,
+ selected: store.getters.activeIssue.assignees,
+ participants,
+ };
+ },
+ store,
+ provide: {
+ canUpdate: true,
+ rootPath: '',
+ },
+ });
+ };
+
+ const createComponentWithApollo = (search = '') => {
+ fakeApollo = createMockApollo([
+ [getIssueParticipants, getIssueParticipantsSpy],
+ [searchUsers, getSearchUsersSpy],
+ ]);
+
+ wrapper = mount(BoardAssigneeDropdown, {
+ localVue,
+ apolloProvider: fakeApollo,
+ data() {
+ return {
+ search,
+ selected: store.getters.activeIssue.assignees,
+ participants,
+ };
+ },
+ store,
+ provide: {
+ canUpdate: true,
+ rootPath: '',
+ },
+ });
+ };
+
+ const unassign = async () => {
+ wrapper.find('[data-testid="unassign"]').trigger('click');
+
+ await wrapper.vm.$nextTick();
+ };
+
+ const openDropdown = async () => {
+ wrapper.find('[data-testid="edit-button"]').trigger('click');
+
+ await wrapper.vm.$nextTick();
+ };
+
+ const findByText = text => {
+ return wrapper.findAll(GlDropdownItem).wrappers.find(node => node.text().indexOf(text) === 0);
+ };
+
+ beforeEach(() => {
+ store.state.activeId = '1';
+ store.state.issues = {
+ '1': {
+ iid,
+ assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }],
+ },
+ };
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when mounted', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it.each`
+ text
+ ${anotherIssueName}
+ ${activeIssueName}
+ `('finds item with $text', ({ text }) => {
+ const item = findByText(text);
+
+ expect(item.exists()).toBe(true);
+ });
+
+ it('renders gl-avatar-link in gl-dropdown-item', () => {
+ const item = findByText('hello');
+
+ expect(item.find(GlAvatarLink).exists()).toBe(true);
+ });
+
+ it('renders gl-avatar-labeled in gl-avatar-link', () => {
+ const item = findByText('hello');
+
+ expect(
+ item
+ .find(GlAvatarLink)
+ .find(GlAvatarLabeled)
+ .exists(),
+ ).toBe(true);
+ });
+ });
+
+ describe('when selected users are present', () => {
+ it('renders a divider', () => {
+ createComponent();
+
+ expect(wrapper.find('[data-testid="selected-user-divider"]').exists()).toBe(true);
+ });
+ });
+
+ describe('when collapsed', () => {
+ it('renders IssuableAssignees', () => {
+ createComponent();
+
+ expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
+ expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(false);
+ });
+ });
+
+ describe('when dropdown is open', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ await openDropdown();
+ });
+
+ it('shows assignees dropdown', async () => {
+ expect(wrapper.find(IssuableAssignees).isVisible()).toBe(false);
+ expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(true);
+ });
+
+ it('shows the issue returned as the activeIssue', async () => {
+ expect(findByText(activeIssueName).props('isChecked')).toBe(true);
+ });
+
+ describe('when "Unassign" is clicked', () => {
+ it('unassigns assignees', async () => {
+ await unassign();
+
+ expect(findByText('Unassign').props('isChecked')).toBe(true);
+ });
+ });
+
+ describe('when an unselected item is clicked', () => {
+ beforeEach(async () => {
+ await unassign();
+ });
+
+ it('assigns assignee in the dropdown', async () => {
+ wrapper.find('[data-testid="item_test"]').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findByText(activeIssueName).props('isChecked')).toBe(true);
+ });
+
+ it('calls setAssignees with username list', async () => {
+ wrapper.find('[data-testid="item_test"]').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ document.body.click();
+
+ await wrapper.vm.$nextTick();
+
+ expect(store.dispatch).toHaveBeenCalledWith('setAssignees', [activeIssueName]);
+ });
+ });
+
+ describe('when the user off clicks', () => {
+ beforeEach(async () => {
+ await unassign();
+
+ document.body.click();
+
+ await wrapper.vm.$nextTick();
+ });
+
+ it('calls setAssignees with username list', async () => {
+ expect(store.dispatch).toHaveBeenCalledWith('setAssignees', []);
+ });
+
+ it('closes the dropdown', async () => {
+ expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
+ });
+ });
+ });
+
+ it('renders divider after unassign', () => {
+ createComponent();
+
+ expect(wrapper.find('[data-testid="unassign-divider"]').exists()).toBe(true);
+ });
+
+ it.each`
+ assignees | expected
+ ${[{ id: 5, username: '', name: '' }]} | ${'Assignee'}
+ ${[{ id: 6, username: '', name: '' }, { id: 7, username: '', name: '' }]} | ${'2 Assignees'}
+ `(
+ 'when assignees have a length of $assignees.length, it renders $expected',
+ ({ assignees, expected }) => {
+ store.state.issues['1'].assignees = assignees;
+
+ createComponent();
+
+ expect(wrapper.find(BoardEditableItem).props('title')).toBe(expected);
+ },
+ );
+
+ describe('Apollo', () => {
+ beforeEach(() => {
+ getIssueParticipantsSpy = jest.fn().mockResolvedValue({
+ data: {
+ issue: {
+ participants: {
+ nodes: [
+ {
+ username: 'participant',
+ name: 'participant',
+ webUrl: '',
+ avatarUrl: '',
+ id: '',
+ },
+ ],
+ },
+ },
+ },
+ });
+ getSearchUsersSpy = jest.fn().mockResolvedValue({
+ data: {
+ users: {
+ nodes: [{ username: 'root', name: 'root', webUrl: '', avatarUrl: '', id: '' }],
+ },
+ },
+ });
+ });
+
+ describe('when search is empty', () => {
+ beforeEach(() => {
+ createComponentWithApollo();
+ });
+
+ it('calls getIssueParticipants', async () => {
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(getIssueParticipantsSpy).toHaveBeenCalledWith({ id: 'gid://gitlab/Issue/111' });
+ });
+ });
+
+ describe('when search is not empty', () => {
+ beforeEach(() => {
+ createComponentWithApollo('search term');
+ });
+
+ it('calls searchUsers', async () => {
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(getSearchUsersSpy).toHaveBeenCalledWith({ search: 'search term' });
+ });
+ });
+ });
+
+ it('finds GlSearchBoxByType', async () => {
+ createComponent();
+
+ await openDropdown();
+
+ expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/boards/components/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js
index a3ddcdf01b7..5e23c781eae 100644
--- a/spec/frontend/boards/components/board_card_spec.js
+++ b/spec/frontend/boards/components/board_card_spec.js
@@ -175,7 +175,7 @@ describe('BoardCard', () => {
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
- expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, undefined);
+ expect(eventHub.$emit).toHaveBeenCalledWith('newDetailIssue', wrapper.vm.issue, false);
expect(boardsStore.detail.list).toEqual(wrapper.vm.list);
});
@@ -188,7 +188,7 @@ describe('BoardCard', () => {
wrapper.trigger('mousedown');
wrapper.trigger('mouseup');
- expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', undefined);
+ expect(eventHub.$emit).toHaveBeenCalledWith('clearDetailIssue', false);
});
});
diff --git a/spec/frontend/boards/components/board_column_new_spec.js b/spec/frontend/boards/components/board_column_new_spec.js
new file mode 100644
index 00000000000..4aafc3a867a
--- /dev/null
+++ b/spec/frontend/boards/components/board_column_new_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { listObj } from 'jest/boards/mock_data';
+import BoardColumn from '~/boards/components/board_column_new.vue';
+import List from '~/boards/models/list';
+import { ListType } from '~/boards/constants';
+import { createStore } from '~/boards/stores';
+
+describe('Board Column Component', () => {
+ let wrapper;
+ let store;
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const createComponent = ({ listType = ListType.backlog, collapsed = false } = {}) => {
+ const boardId = '1';
+
+ const listMock = {
+ ...listObj,
+ list_type: listType,
+ collapsed,
+ };
+
+ if (listType === ListType.assignee) {
+ delete listMock.label;
+ listMock.user = {};
+ }
+
+ const list = new List({ ...listMock, doNotFetchIssues: true });
+
+ store = createStore();
+
+ wrapper = shallowMount(BoardColumn, {
+ store,
+ propsData: {
+ disabled: false,
+ list,
+ },
+ provide: {
+ boardId,
+ },
+ });
+ };
+
+ const isExpandable = () => wrapper.classes('is-expandable');
+ const isCollapsed = () => wrapper.classes('is-collapsed');
+
+ describe('Given different list types', () => {
+ it('is expandable when List Type is `backlog`', () => {
+ createComponent({ listType: ListType.backlog });
+
+ expect(isExpandable()).toBe(true);
+ });
+ });
+
+ describe('expanded / collapsed column', () => {
+ it('has class is-collapsed when list is collapsed', () => {
+ createComponent({ collapsed: false });
+
+ expect(wrapper.vm.list.isExpanded).toBe(true);
+ });
+
+ it('does not have class is-collapsed when list is expanded', () => {
+ createComponent({ collapsed: true });
+
+ expect(isCollapsed()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js
index 2a4dbbb989e..ba11225676b 100644
--- a/spec/frontend/boards/components/board_column_spec.js
+++ b/spec/frontend/boards/components/board_column_spec.js
@@ -78,7 +78,7 @@ describe('Board Column Component', () => {
});
});
- describe('expanded / collaped column', () => {
+ describe('expanded / collapsed column', () => {
it('has class is-collapsed when list is collapsed', () => {
createComponent({ collapsed: false });
diff --git a/spec/frontend/boards/components/board_list_header_new_spec.js b/spec/frontend/boards/components/board_list_header_new_spec.js
new file mode 100644
index 00000000000..80786d82620
--- /dev/null
+++ b/spec/frontend/boards/components/board_list_header_new_spec.js
@@ -0,0 +1,169 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+import { listObj } from 'jest/boards/mock_data';
+import BoardListHeader from '~/boards/components/board_list_header_new.vue';
+import List from '~/boards/models/list';
+import { ListType } from '~/boards/constants';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('Board List Header Component', () => {
+ let wrapper;
+ let store;
+
+ const updateListSpy = jest.fn();
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+
+ localStorage.clear();
+ });
+
+ const createComponent = ({
+ listType = ListType.backlog,
+ collapsed = false,
+ withLocalStorage = true,
+ currentUserId = null,
+ } = {}) => {
+ const boardId = '1';
+
+ const listMock = {
+ ...listObj,
+ list_type: listType,
+ collapsed,
+ };
+
+ if (listType === ListType.assignee) {
+ delete listMock.label;
+ listMock.user = {};
+ }
+
+ const list = new List({ ...listMock, doNotFetchIssues: true });
+
+ if (withLocalStorage) {
+ localStorage.setItem(
+ `boards.${boardId}.${list.type}.${list.id}.expanded`,
+ (!collapsed).toString(),
+ );
+ }
+
+ store = new Vuex.Store({
+ state: {},
+ actions: { updateList: updateListSpy },
+ getters: {},
+ });
+
+ wrapper = shallowMount(BoardListHeader, {
+ store,
+ localVue,
+ propsData: {
+ disabled: false,
+ list,
+ },
+ provide: {
+ boardId,
+ weightFeatureAvailable: false,
+ currentUserId,
+ },
+ });
+ };
+
+ const isExpanded = () => wrapper.vm.list.isExpanded;
+ const isCollapsed = () => !isExpanded();
+
+ const findAddIssueButton = () => wrapper.find({ ref: 'newIssueBtn' });
+ const findCaret = () => wrapper.find('.board-title-caret');
+
+ describe('Add issue button', () => {
+ const hasNoAddButton = [ListType.promotion, ListType.blank, ListType.closed];
+ const hasAddButton = [ListType.backlog, ListType.label, ListType.milestone, ListType.assignee];
+
+ it.each(hasNoAddButton)('does not render when List Type is `%s`', listType => {
+ createComponent({ listType });
+
+ expect(findAddIssueButton().exists()).toBe(false);
+ });
+
+ it.each(hasAddButton)('does render when List Type is `%s`', listType => {
+ createComponent({ listType });
+
+ expect(findAddIssueButton().exists()).toBe(true);
+ });
+
+ it('has a test for each list type', () => {
+ createComponent();
+
+ Object.values(ListType).forEach(value => {
+ expect([...hasAddButton, ...hasNoAddButton]).toContain(value);
+ });
+ });
+
+ it('does render when logged out', () => {
+ createComponent();
+
+ expect(findAddIssueButton().exists()).toBe(true);
+ });
+ });
+
+ describe('expanding / collapsing the column', () => {
+ it('does not collapse when clicking the header', async () => {
+ createComponent();
+
+ expect(isCollapsed()).toBe(false);
+
+ wrapper.find('[data-testid="board-list-header"]').trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(isCollapsed()).toBe(false);
+ });
+
+ it('collapses expanded Column when clicking the collapse icon', async () => {
+ createComponent();
+
+ expect(isExpanded()).toBe(true);
+
+ findCaret().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(isCollapsed()).toBe(true);
+ });
+
+ it('expands collapsed Column when clicking the expand icon', async () => {
+ createComponent({ collapsed: true });
+
+ expect(isCollapsed()).toBe(true);
+
+ findCaret().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(isCollapsed()).toBe(false);
+ });
+
+ it("when logged in it calls list update and doesn't set localStorage", async () => {
+ createComponent({ withLocalStorage: false, currentUserId: 1 });
+
+ findCaret().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+
+ expect(updateListSpy).toHaveBeenCalledTimes(1);
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(null);
+ });
+
+ it("when logged out it doesn't call list update and sets localStorage", async () => {
+ createComponent();
+
+ findCaret().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+
+ expect(updateListSpy).not.toHaveBeenCalled();
+ expect(localStorage.getItem(`${wrapper.vm.uniqueKey}.expanded`)).toBe(String(isExpanded()));
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/board_new_issue_new_spec.js b/spec/frontend/boards/components/board_new_issue_new_spec.js
new file mode 100644
index 00000000000..af4bad65121
--- /dev/null
+++ b/spec/frontend/boards/components/board_new_issue_new_spec.js
@@ -0,0 +1,115 @@
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import BoardNewIssue from '~/boards/components/board_new_issue_new.vue';
+
+import '~/boards/models/list';
+import { mockListsWithModel } from '../mock_data';
+
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+
+describe('Issue boards new issue form', () => {
+ let wrapper;
+ let vm;
+
+ const addListNewIssuesSpy = jest.fn();
+
+ const findSubmitButton = () => wrapper.find({ ref: 'submitButton' });
+ const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
+ const findSubmitForm = () => wrapper.find({ ref: 'submitForm' });
+
+ const submitIssue = () => {
+ const dummySubmitEvent = {
+ preventDefault() {},
+ };
+
+ return findSubmitForm().trigger('submit', dummySubmitEvent);
+ };
+
+ beforeEach(() => {
+ const store = new Vuex.Store({
+ state: {},
+ actions: { addListNewIssue: addListNewIssuesSpy },
+ getters: {},
+ });
+
+ wrapper = shallowMount(BoardNewIssue, {
+ propsData: {
+ disabled: false,
+ list: mockListsWithModel[0],
+ },
+ store,
+ localVue,
+ provide: {
+ groupId: null,
+ weightFeatureAvailable: false,
+ boardWeight: null,
+ },
+ });
+
+ vm = wrapper.vm;
+
+ return vm.$nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('calls submit if submit button is clicked', async () => {
+ jest.spyOn(wrapper.vm, 'submit').mockImplementation();
+ wrapper.setData({ title: 'Testing Title' });
+
+ await vm.$nextTick();
+ await submitIssue();
+ expect(wrapper.vm.submit).toHaveBeenCalled();
+ });
+
+ it('disables submit button if title is empty', () => {
+ expect(findSubmitButton().props().disabled).toBe(true);
+ });
+
+ it('enables submit button if title is not empty', async () => {
+ wrapper.setData({ title: 'Testing Title' });
+
+ await vm.$nextTick();
+ expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title');
+ expect(findSubmitButton().props().disabled).toBe(false);
+ });
+
+ it('clears title after clicking cancel', async () => {
+ findCancelButton().trigger('click');
+
+ await vm.$nextTick();
+ expect(vm.title).toBe('');
+ });
+
+ describe('submit success', () => {
+ it('creates new issue', async () => {
+ wrapper.setData({ title: 'submit issue' });
+
+ await vm.$nextTick();
+ await submitIssue();
+ expect(addListNewIssuesSpy).toHaveBeenCalled();
+ });
+
+ it('enables button after submit', async () => {
+ jest.spyOn(wrapper.vm, 'submit').mockImplementation();
+ wrapper.setData({ title: 'submit issue' });
+
+ await vm.$nextTick();
+ await submitIssue();
+ expect(findSubmitButton().props().disabled).toBe(false);
+ });
+
+ it('clears title after submit', async () => {
+ wrapper.setData({ title: 'submit issue' });
+
+ await vm.$nextTick();
+ await submitIssue();
+ await vm.$nextTick();
+ expect(vm.title).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js
new file mode 100644
index 00000000000..b034c8cb11d
--- /dev/null
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_due_date_spec.js
@@ -0,0 +1,137 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlDatepicker } from '@gitlab/ui';
+import BoardSidebarDueDate from '~/boards/components/sidebar/board_sidebar_due_date.vue';
+import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
+import { createStore } from '~/boards/stores';
+import createFlash from '~/flash';
+
+const TEST_DUE_DATE = '2020-02-20';
+const TEST_FORMATTED_DUE_DATE = 'Feb 20, 2020';
+const TEST_PARSED_DATE = new Date(2020, 1, 20);
+const TEST_ISSUE = { id: 'gid://gitlab/Issue/1', iid: 9, dueDate: null, referencePath: 'h/b#2' };
+
+jest.mock('~/flash');
+
+describe('~/boards/components/sidebar/board_sidebar_due_date.vue', () => {
+ let wrapper;
+ let store;
+
+ afterEach(() => {
+ wrapper.destroy();
+ store = null;
+ wrapper = null;
+ });
+
+ const createWrapper = ({ dueDate = null } = {}) => {
+ store = createStore();
+ store.state.issues = { [TEST_ISSUE.id]: { ...TEST_ISSUE, dueDate } };
+ store.state.activeId = TEST_ISSUE.id;
+
+ wrapper = shallowMount(BoardSidebarDueDate, {
+ store,
+ provide: {
+ canUpdate: true,
+ },
+ stubs: {
+ 'board-editable-item': BoardEditableItem,
+ },
+ });
+ };
+
+ const findDatePicker = () => wrapper.find(GlDatepicker);
+ const findResetButton = () => wrapper.find('[data-testid="reset-button"]');
+ const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
+
+ it('renders "None" when no due date is set', () => {
+ createWrapper();
+
+ expect(findCollapsed().text()).toBe('None');
+ expect(findResetButton().exists()).toBe(false);
+ });
+
+ it('renders formatted due date with reset button when set', () => {
+ createWrapper({ dueDate: TEST_DUE_DATE });
+
+ expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
+ expect(findResetButton().exists()).toBe(true);
+ });
+
+ describe('when due date is submitted', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
+ store.state.issues[TEST_ISSUE.id].dueDate = TEST_DUE_DATE;
+ });
+ findDatePicker().vm.$emit('input', TEST_PARSED_DATE);
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders formatted due date with reset button', () => {
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
+ expect(findResetButton().exists()).toBe(true);
+ });
+
+ it('commits change to the server', () => {
+ expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalledWith({
+ dueDate: TEST_DUE_DATE,
+ projectPath: 'h/b',
+ });
+ });
+ });
+
+ describe('when due date is cleared', () => {
+ beforeEach(async () => {
+ createWrapper();
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
+ store.state.issues[TEST_ISSUE.id].dueDate = null;
+ });
+ findDatePicker().vm.$emit('clear');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders "None"', () => {
+ expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled();
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findCollapsed().text()).toBe('None');
+ });
+ });
+
+ describe('when due date is resetted', () => {
+ beforeEach(async () => {
+ createWrapper({ dueDate: TEST_DUE_DATE });
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
+ store.state.issues[TEST_ISSUE.id].dueDate = null;
+ });
+ findResetButton().vm.$emit('click');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders "None"', () => {
+ expect(wrapper.vm.setActiveIssueDueDate).toHaveBeenCalled();
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findCollapsed().text()).toBe('None');
+ });
+ });
+
+ describe('when the mutation fails', () => {
+ beforeEach(async () => {
+ createWrapper({ dueDate: TEST_DUE_DATE });
+
+ jest.spyOn(wrapper.vm, 'setActiveIssueDueDate').mockImplementation(() => {
+ throw new Error(['failed mutation']);
+ });
+ findDatePicker().vm.$emit('input', 'Invalid date');
+ await wrapper.vm.$nextTick();
+ });
+
+ it('collapses sidebar and renders former issue due date', () => {
+ expect(findCollapsed().isVisible()).toBe(true);
+ expect(findCollapsed().text()).toContain(TEST_FORMATTED_DUE_DATE);
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
new file mode 100644
index 00000000000..ee54c662167
--- /dev/null
+++ b/spec/frontend/boards/components/sidebar/board_sidebar_subscription_spec.js
@@ -0,0 +1,157 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
+import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
+import * as types from '~/boards/stores/mutation_types';
+import { createStore } from '~/boards/stores';
+import { mockActiveIssue } from '../../mock_data';
+import createFlash from '~/flash';
+
+jest.mock('~/flash.js');
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => {
+ let wrapper;
+ let store;
+
+ const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']");
+ const findToggle = () => wrapper.find(GlToggle);
+ const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const createComponent = (activeIssue = { ...mockActiveIssue }) => {
+ store = createStore();
+ store.state.issues = { [activeIssue.id]: activeIssue };
+ store.state.activeId = activeIssue.id;
+
+ wrapper = mount(BoardSidebarSubscription, {
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ store = null;
+ jest.clearAllMocks();
+ });
+
+ describe('Board sidebar subscription component template', () => {
+ it('displays "notifications" heading', () => {
+ createComponent();
+
+ expect(findNotificationHeader().text()).toBe('Notifications');
+ });
+
+ it('renders toggle as "off" when currently not subscribed', () => {
+ createComponent();
+
+ expect(findToggle().exists()).toBe(true);
+ expect(findToggle().props('value')).toBe(false);
+ });
+
+ it('renders toggle as "on" when currently subscribed', () => {
+ createComponent({
+ ...mockActiveIssue,
+ subscribed: true,
+ });
+
+ expect(findToggle().exists()).toBe(true);
+ expect(findToggle().props('value')).toBe(true);
+ });
+
+ describe('when notification emails have been disabled', () => {
+ beforeEach(() => {
+ createComponent({
+ ...mockActiveIssue,
+ emailsDisabled: true,
+ });
+ });
+
+ it('displays a message that notification have been disabled', () => {
+ expect(findNotificationHeader().text()).toBe(
+ 'Notifications have been disabled by the project or group owner',
+ );
+ });
+
+ it('does not render the toggle button', () => {
+ expect(findToggle().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Board sidebar subscription component `behavior`', () => {
+ const mockSetActiveIssueSubscribed = subscribedState => {
+ jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => {
+ store.commit(types.UPDATE_ISSUE_BY_ID, {
+ issueId: mockActiveIssue.id,
+ prop: 'subscribed',
+ value: subscribedState,
+ });
+ });
+ };
+
+ it('subscribing to notification', async () => {
+ createComponent();
+ mockSetActiveIssueSubscribed(true);
+
+ expect(findGlLoadingIcon().exists()).toBe(false);
+
+ findToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findGlLoadingIcon().exists()).toBe(true);
+ expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({
+ subscribed: true,
+ projectPath: 'gitlab-org/test-subgroup/gitlab-test',
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findToggle().props('value')).toBe(true);
+ });
+
+ it('unsubscribing from notification', async () => {
+ createComponent({
+ ...mockActiveIssue,
+ subscribed: true,
+ });
+ mockSetActiveIssueSubscribed(false);
+
+ expect(findGlLoadingIcon().exists()).toBe(false);
+
+ findToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.setActiveIssueSubscribed).toHaveBeenCalledWith({
+ subscribed: false,
+ projectPath: 'gitlab-org/test-subgroup/gitlab-test',
+ });
+ expect(findGlLoadingIcon().exists()).toBe(true);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findGlLoadingIcon().exists()).toBe(false);
+ expect(findToggle().props('value')).toBe(false);
+ });
+
+ it('flashes an error message when setting the subscribed state fails', async () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, 'setActiveIssueSubscribed').mockImplementation(async () => {
+ throw new Error();
+ });
+
+ findToggle().trigger('click');
+
+ await wrapper.vm.$nextTick();
+ expect(createFlash).toHaveBeenNthCalledWith(1, {
+ message: wrapper.vm.$options.i18n.updateSubscribedErrorMessage,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js
index 50c0a85fc70..58f67231d55 100644
--- a/spec/frontend/boards/mock_data.js
+++ b/spec/frontend/boards/mock_data.js
@@ -2,6 +2,7 @@
/* global List */
import Vue from 'vue';
+import { keyBy } from 'lodash';
import '~/boards/models/list';
import '~/boards/models/issue';
import boardsStore from '~/boards/stores/boards_store';
@@ -175,6 +176,14 @@ export const mockIssue = {
},
};
+export const mockActiveIssue = {
+ ...mockIssue,
+ id: 436,
+ iid: '27',
+ subscribed: false,
+ emailsDisabled: false,
+};
+
export const mockIssueWithModel = new ListIssue(mockIssue);
export const mockIssue2 = {
@@ -290,6 +299,7 @@ export const mockLists = [
assignee: null,
milestone: null,
loading: false,
+ issuesSize: 1,
},
{
id: 'gid://gitlab/List/2',
@@ -307,9 +317,12 @@ export const mockLists = [
assignee: null,
milestone: null,
loading: false,
+ issuesSize: 0,
},
];
+export const mockListsById = keyBy(mockLists, 'id');
+
export const mockListsWithModel = mockLists.map(listMock =>
Vue.observable(new List({ ...listMock, doNotFetchIssues: true })),
);
@@ -319,6 +332,23 @@ export const mockIssuesByListId = {
'gid://gitlab/List/2': mockIssues.map(({ id }) => id),
};
+export const participants = [
+ {
+ id: '1',
+ username: 'test',
+ name: 'test',
+ avatar: '',
+ avatarUrl: '',
+ },
+ {
+ id: '2',
+ username: 'hello',
+ name: 'hello',
+ avatar: '',
+ avatarUrl: '',
+ },
+];
+
export const issues = {
[mockIssue.id]: mockIssue,
[mockIssue2.id]: mockIssue2,
diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js
index 78e70161121..4d529580a7a 100644
--- a/spec/frontend/boards/stores/actions_spec.js
+++ b/spec/frontend/boards/stores/actions_spec.js
@@ -2,17 +2,21 @@ import testAction from 'helpers/vuex_action_helper';
import {
mockListsWithModel,
mockLists,
+ mockListsById,
mockIssue,
mockIssueWithModel,
mockIssue2WithModel,
rawIssue,
mockIssues,
labels,
+ mockActiveIssue,
} from '../mock_data';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
-import { inactiveId, ListType } from '~/boards/constants';
+import { inactiveId } from '~/boards/constants';
import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql';
+import destroyBoardListMutation from '~/boards/queries/board_list_destroy.mutation.graphql';
+import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util';
const expectNotImplemented = action => {
@@ -116,7 +120,7 @@ describe('fetchLists', () => {
payload: formattedLists,
},
],
- [{ type: 'showWelcomeList' }],
+ [{ type: 'generateDefaultLists' }],
done,
);
});
@@ -146,14 +150,15 @@ describe('fetchLists', () => {
payload: formattedLists,
},
],
- [{ type: 'createList', payload: { backlog: true } }, { type: 'showWelcomeList' }],
+ [{ type: 'createList', payload: { backlog: true } }, { type: 'generateDefaultLists' }],
done,
);
});
});
-describe('showWelcomeList', () => {
- it('should dispatch addList action', done => {
+describe('generateDefaultLists', () => {
+ let store;
+ beforeEach(() => {
const state = {
endpoints: { fullPath: 'gitlab-org', boardId: '1' },
boardType: 'group',
@@ -161,26 +166,19 @@ describe('showWelcomeList', () => {
boardLists: [{ type: 'backlog' }, { type: 'closed' }],
};
- const blankList = {
- id: 'blank',
- listType: ListType.blank,
- title: 'Welcome to your issue board!',
- position: 0,
- };
-
- testAction(
- actions.showWelcomeList,
- {},
+ store = {
+ commit: jest.fn(),
+ dispatch: jest.fn(() => Promise.resolve()),
state,
- [],
- [{ type: 'addList', payload: blankList }],
- done,
- );
+ };
});
-});
-describe('generateDefaultLists', () => {
- expectNotImplemented(actions.generateDefaultLists);
+ it('should dispatch fetchLabels', () => {
+ return actions.generateDefaultLists(store).then(() => {
+ expect(store.dispatch.mock.calls[0]).toEqual(['fetchLabels', 'to do']);
+ expect(store.dispatch.mock.calls[1]).toEqual(['fetchLabels', 'doing']);
+ });
+ });
});
describe('createList', () => {
@@ -323,8 +321,82 @@ describe('updateList', () => {
});
});
-describe('deleteList', () => {
- expectNotImplemented(actions.deleteList);
+describe('removeList', () => {
+ let state;
+ const list = mockLists[0];
+ const listId = list.id;
+ const mutationVariables = {
+ mutation: destroyBoardListMutation,
+ variables: {
+ listId,
+ },
+ };
+
+ beforeEach(() => {
+ state = {
+ boardLists: mockListsById,
+ };
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ it('optimistically deletes the list', () => {
+ const commit = jest.fn();
+
+ actions.removeList({ commit, state }, listId);
+
+ expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
+ });
+
+ it('keeps the updated list if remove succeeds', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ destroyBoardList: {
+ errors: [],
+ },
+ },
+ });
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([[types.REMOVE_LIST, listId]]);
+ });
+
+ it('restores the list if update fails', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue(Promise.reject());
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([
+ [types.REMOVE_LIST, listId],
+ [types.REMOVE_LIST_FAILURE, mockListsById],
+ ]);
+ });
+
+ it('restores the list if update response has errors', async () => {
+ const commit = jest.fn();
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ destroyBoardList: {
+ errors: ['update failed, ID invalid'],
+ },
+ },
+ });
+
+ await actions.removeList({ commit, state }, listId);
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables);
+ expect(commit.mock.calls).toEqual([
+ [types.REMOVE_LIST, listId],
+ [types.REMOVE_LIST_FAILURE, mockListsById],
+ ]);
+ });
});
describe('fetchIssuesForList', () => {
@@ -560,41 +632,106 @@ describe('moveIssue', () => {
});
});
-describe('createNewIssue', () => {
- expectNotImplemented(actions.createNewIssue);
+describe('setAssignees', () => {
+ const node = { username: 'name' };
+ const name = 'username';
+ const projectPath = 'h/h';
+ const refPath = `${projectPath}#3`;
+ const iid = '1';
+
+ beforeEach(() => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } },
+ });
+ });
+
+ it('calls mutate with the correct values', async () => {
+ await actions.setAssignees(
+ { commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } },
+ [name],
+ );
+
+ expect(gqlClient.mutate).toHaveBeenCalledWith({
+ mutation: updateAssignees,
+ variables: { iid, assigneeUsernames: [name], projectPath },
+ });
+ });
+
+ it('calls the correct mutation with the correct values', done => {
+ testAction(
+ actions.setAssignees,
+ {},
+ { activeIssue: { iid, referencePath: refPath }, commit: () => {} },
+ [
+ {
+ type: 'UPDATE_ISSUE_BY_ID',
+ payload: { prop: 'assignees', issueId: undefined, value: [node] },
+ },
+ ],
+ [],
+ done,
+ );
+ });
});
-describe('addListIssue', () => {
- it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
- const payload = {
- list: mockLists[0],
- issue: mockIssue,
- position: 0,
- };
+describe('createNewIssue', () => {
+ const state = {
+ boardType: 'group',
+ endpoints: {
+ fullPath: 'gitlab-org/gitlab',
+ },
+ };
+
+ it('should return issue from API on success', async () => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ createIssue: {
+ issue: mockIssue,
+ errors: [],
+ },
+ },
+ });
+
+ const result = await actions.createNewIssue({ state }, mockIssue);
+ expect(result).toEqual(mockIssue);
+ });
+
+ it('should commit CREATE_ISSUE_FAILURE mutation when API returns an error', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ createIssue: {
+ issue: {},
+ errors: [{ foo: 'bar' }],
+ },
+ },
+ });
+
+ const payload = mockIssue;
testAction(
- actions.addListIssue,
+ actions.createNewIssue,
payload,
- {},
- [{ type: types.ADD_ISSUE_TO_LIST, payload }],
+ state,
+ [{ type: types.CREATE_ISSUE_FAILURE }],
[],
done,
);
});
});
-describe('addListIssueFailure', () => {
- it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => {
+describe('addListIssue', () => {
+ it('should commit ADD_ISSUE_TO_LIST mutation', done => {
const payload = {
list: mockLists[0],
issue: mockIssue,
+ position: 0,
};
testAction(
- actions.addListIssueFailure,
+ actions.addListIssue,
payload,
{},
- [{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }],
+ [{ type: types.ADD_ISSUE_TO_LIST, payload }],
[],
done,
);
@@ -603,7 +740,7 @@ describe('addListIssueFailure', () => {
describe('setActiveIssueLabels', () => {
const state = { issues: { [mockIssue.id]: mockIssue } };
- const getters = { getActiveIssue: mockIssue };
+ const getters = { activeIssue: mockIssue };
const testLabelIds = labels.map(label => label.id);
const input = {
addLabelIds: testLabelIds,
@@ -617,7 +754,7 @@ describe('setActiveIssueLabels', () => {
.mockResolvedValue({ data: { updateIssue: { issue: { labels: { nodes: labels } } } } });
const payload = {
- issueId: getters.getActiveIssue.id,
+ issueId: getters.activeIssue.id,
prop: 'labels',
value: labels,
};
@@ -646,6 +783,108 @@ describe('setActiveIssueLabels', () => {
});
});
+describe('setActiveIssueDueDate', () => {
+ const state = { issues: { [mockIssue.id]: mockIssue } };
+ const getters = { activeIssue: mockIssue };
+ const testDueDate = '2020-02-20';
+ const input = {
+ dueDate: testDueDate,
+ projectPath: 'h/b',
+ };
+
+ it('should commit due date after setting the issue', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ updateIssue: {
+ issue: {
+ dueDate: testDueDate,
+ },
+ errors: [],
+ },
+ },
+ });
+
+ const payload = {
+ issueId: getters.activeIssue.id,
+ prop: 'dueDate',
+ value: testDueDate,
+ };
+
+ testAction(
+ actions.setActiveIssueDueDate,
+ input,
+ { ...state, ...getters },
+ [
+ {
+ type: types.UPDATE_ISSUE_BY_ID,
+ payload,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('throws error if fails', async () => {
+ jest
+ .spyOn(gqlClient, 'mutate')
+ .mockResolvedValue({ data: { updateIssue: { errors: ['failed mutation'] } } });
+
+ await expect(actions.setActiveIssueDueDate({ getters }, input)).rejects.toThrow(Error);
+ });
+});
+
+describe('setActiveIssueSubscribed', () => {
+ const state = { issues: { [mockActiveIssue.id]: mockActiveIssue } };
+ const getters = { activeIssue: mockActiveIssue };
+ const subscribedState = true;
+ const input = {
+ subscribedState,
+ projectPath: 'gitlab-org/gitlab-test',
+ };
+
+ it('should commit subscribed status', done => {
+ jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
+ data: {
+ issueSetSubscription: {
+ issue: {
+ subscribed: subscribedState,
+ },
+ errors: [],
+ },
+ },
+ });
+
+ const payload = {
+ issueId: getters.activeIssue.id,
+ prop: 'subscribed',
+ value: subscribedState,
+ };
+
+ testAction(
+ actions.setActiveIssueSubscribed,
+ input,
+ { ...state, ...getters },
+ [
+ {
+ type: types.UPDATE_ISSUE_BY_ID,
+ payload,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+
+ it('throws error if fails', async () => {
+ jest
+ .spyOn(gqlClient, 'mutate')
+ .mockResolvedValue({ data: { issueSetSubscription: { errors: ['failed mutation'] } } });
+
+ await expect(actions.setActiveIssueSubscribed({ getters }, input)).rejects.toThrow(Error);
+ });
+});
+
describe('fetchBacklog', () => {
expectNotImplemented(actions.fetchBacklog);
});
diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js
index b987080abab..64025726dd1 100644
--- a/spec/frontend/boards/stores/getters_spec.js
+++ b/spec/frontend/boards/stores/getters_spec.js
@@ -10,13 +10,13 @@ import {
} from '../mock_data';
describe('Boards - Getters', () => {
- describe('getLabelToggleState', () => {
+ describe('labelToggleState', () => {
it('should return "on" when isShowingLabels is true', () => {
const state = {
isShowingLabels: true,
};
- expect(getters.getLabelToggleState(state)).toBe('on');
+ expect(getters.labelToggleState(state)).toBe('on');
});
it('should return "off" when isShowingLabels is false', () => {
@@ -24,7 +24,7 @@ describe('Boards - Getters', () => {
isShowingLabels: false,
};
- expect(getters.getLabelToggleState(state)).toBe('off');
+ expect(getters.labelToggleState(state)).toBe('off');
});
});
@@ -112,7 +112,7 @@ describe('Boards - Getters', () => {
});
});
- describe('getActiveIssue', () => {
+ describe('activeIssue', () => {
it.each`
id | expected
${'1'} | ${'issue'}
@@ -120,11 +120,27 @@ describe('Boards - Getters', () => {
`('returns $expected when $id is passed to state', ({ id, expected }) => {
const state = { issues: { '1': 'issue' }, activeId: id };
- expect(getters.getActiveIssue(state)).toEqual(expected);
+ expect(getters.activeIssue(state)).toEqual(expected);
});
});
- describe('getIssues', () => {
+ describe('projectPathByIssueId', () => {
+ it('returns project path for the active issue', () => {
+ const mockActiveIssue = {
+ referencePath: 'gitlab-org/gitlab-test#1',
+ };
+ expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual(
+ 'gitlab-org/gitlab-test',
+ );
+ });
+
+ it('returns empty string as project when active issue is an empty object', () => {
+ const mockActiveIssue = {};
+ expect(getters.projectPathForActiveIssue({}, { activeIssue: mockActiveIssue })).toEqual('');
+ });
+ });
+
+ describe('getIssuesByList', () => {
const boardsState = {
issuesByListId: mockIssuesByListId,
issues,
@@ -132,7 +148,7 @@ describe('Boards - Getters', () => {
it('returns issues for a given listId', () => {
const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId);
- expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
+ expect(getters.getIssuesByList(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual(
mockIssues,
);
});
diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js
index 6e53f184bb3..e1e57a8fd43 100644
--- a/spec/frontend/boards/stores/mutations_spec.js
+++ b/spec/frontend/boards/stores/mutations_spec.js
@@ -82,7 +82,7 @@ describe('Board Store Mutations', () => {
mutations.SET_ACTIVE_ID(state, expected);
});
- it('updates aciveListId to be the value that is passed', () => {
+ it('updates activeListId to be the value that is passed', () => {
expect(state.activeId).toBe(expected.id);
});
@@ -101,6 +101,34 @@ describe('Board Store Mutations', () => {
});
});
+ describe('CREATE_LIST_FAILURE', () => {
+ it('sets error message', () => {
+ mutations.CREATE_LIST_FAILURE(state);
+
+ expect(state.error).toEqual('An error occurred while creating the list. Please try again.');
+ });
+ });
+
+ describe('RECEIVE_LABELS_FAILURE', () => {
+ it('sets error message', () => {
+ mutations.RECEIVE_LABELS_FAILURE(state);
+
+ expect(state.error).toEqual(
+ 'An error occurred while fetching labels. Please reload the page.',
+ );
+ });
+ });
+
+ describe('GENERATE_DEFAULT_LISTS_FAILURE', () => {
+ it('sets error message', () => {
+ mutations.GENERATE_DEFAULT_LISTS_FAILURE(state);
+
+ expect(state.error).toEqual(
+ 'An error occurred while generating lists. Please reload the page.',
+ );
+ });
+ });
+
describe('REQUEST_ADD_LIST', () => {
expectNotImplemented(mutations.REQUEST_ADD_LIST);
});
@@ -156,16 +184,43 @@ describe('Board Store Mutations', () => {
});
});
- describe('REQUEST_REMOVE_LIST', () => {
- expectNotImplemented(mutations.REQUEST_REMOVE_LIST);
- });
+ describe('REMOVE_LIST', () => {
+ it('removes list from boardLists', () => {
+ const [list, secondList] = mockListsWithModel;
+ const expected = {
+ [secondList.id]: secondList,
+ };
+ state = {
+ ...state,
+ boardLists: { ...initialBoardListsState },
+ };
- describe('RECEIVE_REMOVE_LIST_SUCCESS', () => {
- expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_SUCCESS);
+ mutations[types.REMOVE_LIST](state, list.id);
+
+ expect(state.boardLists).toEqual(expected);
+ });
});
- describe('RECEIVE_REMOVE_LIST_ERROR', () => {
- expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR);
+ describe('REMOVE_LIST_FAILURE', () => {
+ it('restores lists from backup', () => {
+ const backupLists = { ...initialBoardListsState };
+
+ mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
+
+ expect(state.boardLists).toEqual(backupLists);
+ });
+
+ it('sets error state', () => {
+ const backupLists = { ...initialBoardListsState };
+ state = {
+ ...state,
+ error: undefined,
+ };
+
+ mutations[types.REMOVE_LIST_FAILURE](state, backupLists);
+
+ expect(state.error).toEqual('An error occurred while removing the list. Please try again.');
+ });
});
describe('RESET_ISSUES', () => {
@@ -387,6 +442,14 @@ describe('Board Store Mutations', () => {
expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR);
});
+ describe('CREATE_ISSUE_FAILURE', () => {
+ it('sets error message on state', () => {
+ mutations.CREATE_ISSUE_FAILURE(state);
+
+ expect(state.error).toBe('An error occurred while creating the issue. Please try again.');
+ });
+ });
+
describe('ADD_ISSUE_TO_LIST', () => {
it('adds issue to issues state and issue id in list in issuesByListId', () => {
const listIssues = {
@@ -400,17 +463,45 @@ describe('Board Store Mutations', () => {
...state,
issuesByListId: listIssues,
issues,
+ boardLists: initialBoardListsState,
};
- mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 });
+ expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(1);
+
+ mutations.ADD_ISSUE_TO_LIST(state, { list: mockListsWithModel[0], issue: mockIssue2 });
expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id);
expect(state.issues[mockIssue2.id]).toEqual(mockIssue2);
+ expect(state.boardLists['gid://gitlab/List/1'].issuesSize).toBe(2);
});
});
describe('ADD_ISSUE_TO_LIST_FAILURE', () => {
- it('removes issue id from list in issuesByListId', () => {
+ it('removes issue id from list in issuesByListId and sets error message', () => {
+ const listIssues = {
+ 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
+ };
+ const issues = {
+ '1': mockIssue,
+ '2': mockIssue2,
+ };
+
+ state = {
+ ...state,
+ issuesByListId: listIssues,
+ issues,
+ boardLists: initialBoardListsState,
+ };
+
+ mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
+
+ expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
+ expect(state.error).toBe('An error occurred while creating the issue. Please try again.');
+ });
+ });
+
+ describe('REMOVE_ISSUE_FROM_LIST', () => {
+ it('removes issue id from list in issuesByListId and deletes issue from state', () => {
const listIssues = {
'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id],
};
@@ -426,9 +517,10 @@ describe('Board Store Mutations', () => {
boardLists: initialBoardListsState,
};
- mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 });
+ mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issueId: mockIssue2.id });
expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id);
+ expect(state.issues).not.toContain(mockIssue2);
});
});
diff --git a/spec/frontend/ci_lint/components/ci_lint_results_spec.js b/spec/frontend/ci_lint/components/ci_lint_results_spec.js
index 37575a988c5..93c2d2dbcf3 100644
--- a/spec/frontend/ci_lint/components/ci_lint_results_spec.js
+++ b/spec/frontend/ci_lint/components/ci_lint_results_spec.js
@@ -1,20 +1,24 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { GlTable } from '@gitlab/ui';
+import { GlTable, GlLink } from '@gitlab/ui';
import CiLintResults from '~/ci_lint/components/ci_lint_results.vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { mockJobs, mockErrors, mockWarnings } from '../mock_data';
describe('CI Lint Results', () => {
let wrapper;
+ const defaultProps = {
+ valid: true,
+ jobs: mockJobs,
+ errors: [],
+ warnings: [],
+ dryRun: false,
+ lintHelpPagePath: '/help',
+ };
const createComponent = (props = {}, mountFn = shallowMount) => {
wrapper = mountFn(CiLintResults, {
propsData: {
- valid: true,
- jobs: mockJobs,
- errors: [],
- warnings: [],
- dryRun: false,
+ ...defaultProps,
...props,
},
});
@@ -23,6 +27,7 @@ describe('CI Lint Results', () => {
const findTable = () => wrapper.find(GlTable);
const findByTestId = selector => () => wrapper.find(`[data-testid="ci-lint-${selector}"]`);
const findAllByTestId = selector => () => wrapper.findAll(`[data-testid="ci-lint-${selector}"]`);
+ const findLinkToDoc = () => wrapper.find(GlLink);
const findErrors = findByTestId('errors');
const findWarnings = findByTestId('warnings');
const findStatus = findByTestId('status');
@@ -48,10 +53,15 @@ describe('CI Lint Results', () => {
});
it('displays the invalid status', () => {
- expect(findStatus().text()).toBe(`Status: ${wrapper.vm.$options.incorrect.text}`);
+ expect(findStatus().text()).toContain(`Status: ${wrapper.vm.$options.incorrect.text}`);
expect(findStatus().props('variant')).toBe(wrapper.vm.$options.incorrect.variant);
});
+ it('contains the link to documentation', () => {
+ expect(findLinkToDoc().text()).toBe('More information');
+ expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath);
+ });
+
it('displays the error message', () => {
const [expectedError] = mockErrors;
@@ -66,9 +76,9 @@ describe('CI Lint Results', () => {
});
});
- describe('Valid results', () => {
+ describe('Valid results with dry run', () => {
beforeEach(() => {
- createComponent();
+ createComponent({ dryRun: true }, mount);
});
it('displays table', () => {
@@ -76,13 +86,18 @@ describe('CI Lint Results', () => {
});
it('displays the valid status', () => {
- expect(findStatus().text()).toBe(wrapper.vm.$options.correct.text);
+ expect(findStatus().text()).toContain(wrapper.vm.$options.correct.text);
expect(findStatus().props('variant')).toBe(wrapper.vm.$options.correct.variant);
});
it('does not display only/expect values with dry run', () => {
expect(findOnlyExcept().exists()).toBe(false);
});
+
+ it('contains the link to documentation', () => {
+ expect(findLinkToDoc().text()).toBe('More information');
+ expect(findLinkToDoc().attributes('href')).toBe(defaultProps.lintHelpPagePath);
+ });
});
describe('Lint results', () => {
diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci_lint/components/ci_lint_spec.js
index e617cca499d..b353da5910d 100644
--- a/spec/frontend/ci_lint/components/ci_lint_spec.js
+++ b/spec/frontend/ci_lint/components/ci_lint_spec.js
@@ -1,7 +1,11 @@
+import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
import EditorLite from '~/vue_shared/components/editor_lite.vue';
import CiLint from '~/ci_lint/components/ci_lint.vue';
+import CiLintResults from '~/ci_lint/components/ci_lint_results.vue';
import lintCIMutation from '~/ci_lint/graphql/mutations/lint_ci.mutation.graphql';
+import { mockLintDataValid } from '../mock_data';
describe('CI Lint', () => {
let wrapper;
@@ -9,6 +13,7 @@ describe('CI Lint', () => {
const endpoint = '/namespace/project/-/ci/lint';
const content =
"test_job:\n stage: build\n script: echo 'Building'\n only:\n - web\n - chat\n - pushes\n allow_failure: true ";
+ const mockMutate = jest.fn().mockResolvedValue(mockLintDataValid);
const createComponent = () => {
wrapper = shallowMount(CiLint, {
@@ -19,17 +24,20 @@ describe('CI Lint', () => {
},
propsData: {
endpoint,
- helpPagePath: '/help/ci/lint#pipeline-simulation',
+ pipelineSimulationHelpPagePath: '/help/ci/lint#pipeline-simulation',
+ lintHelpPagePath: '/help/ci/lint#anchor',
},
mocks: {
$apollo: {
- mutate: jest.fn(),
+ mutate: mockMutate,
},
},
});
};
const findEditor = () => wrapper.find(EditorLite);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findCiLintResults = () => wrapper.find(CiLintResults);
const findValidateBtn = () => wrapper.find('[data-testid="ci-lint-validate"]');
const findClearBtn = () => wrapper.find('[data-testid="ci-lint-clear"]');
@@ -38,6 +46,7 @@ describe('CI Lint', () => {
});
afterEach(() => {
+ mockMutate.mockClear();
wrapper.destroy();
});
@@ -67,6 +76,35 @@ describe('CI Lint', () => {
});
});
+ it('validation displays results', async () => {
+ findValidateBtn().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findValidateBtn().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findCiLintResults().exists()).toBe(true);
+ expect(findValidateBtn().props('loading')).toBe(false);
+ });
+
+ it('validation displays error', async () => {
+ mockMutate.mockRejectedValue('Error!');
+
+ findValidateBtn().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findValidateBtn().props('loading')).toBe(true);
+
+ await waitForPromises();
+
+ expect(findCiLintResults().exists()).toBe(false);
+ expect(findAlert().text()).toBe('Error!');
+ expect(findValidateBtn().props('loading')).toBe(false);
+ });
+
it('content is cleared on clear action', async () => {
expect(findEditor().props('value')).toBe(content);
diff --git a/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap
new file mode 100644
index 00000000000..87bec82e350
--- /dev/null
+++ b/spec/frontend/ci_lint/graphql/__snapshots__/resolvers_spec.js.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/ci_lint/graphql/resolvers Mutation lintCI resolves lint data with type names 1`] = `
+Object {
+ "__typename": "CiLintContent",
+ "errors": Array [],
+ "jobs": Array [
+ Object {
+ "__typename": "CiLintJob",
+ "afterScript": Array [
+ "echo 'after script 1",
+ ],
+ "allowFailure": false,
+ "beforeScript": Array [
+ "echo 'before script 1'",
+ ],
+ "environment": "prd",
+ "except": Object {
+ "refs": Array [
+ "master@gitlab-org/gitlab",
+ "/^release/.*$/@gitlab-org/gitlab",
+ ],
+ },
+ "name": "job_1",
+ "only": null,
+ "script": Array [
+ "echo 'script 1'",
+ ],
+ "stage": "test",
+ "tagList": Array [
+ "tag 1",
+ ],
+ "when": "on_success",
+ },
+ Object {
+ "__typename": "CiLintJob",
+ "afterScript": Array [
+ "echo 'after script 2",
+ ],
+ "allowFailure": true,
+ "beforeScript": Array [
+ "echo 'before script 2'",
+ ],
+ "environment": "stg",
+ "except": Object {
+ "refs": Array [
+ "master@gitlab-org/gitlab",
+ "/^release/.*$/@gitlab-org/gitlab",
+ ],
+ },
+ "name": "job_2",
+ "only": Object {
+ "__typename": "CiLintJobOnlyPolicy",
+ "refs": Array [
+ "web",
+ "chat",
+ "pushes",
+ ],
+ },
+ "script": Array [
+ "echo 'script 2'",
+ ],
+ "stage": "test",
+ "tagList": Array [
+ "tag 2",
+ ],
+ "when": "on_success",
+ },
+ ],
+ "valid": true,
+ "warnings": Array [],
+}
+`;
diff --git a/spec/frontend/ci_lint/graphql/resolvers_spec.js b/spec/frontend/ci_lint/graphql/resolvers_spec.js
new file mode 100644
index 00000000000..437c52cf6b4
--- /dev/null
+++ b/spec/frontend/ci_lint/graphql/resolvers_spec.js
@@ -0,0 +1,38 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import httpStatus from '~/lib/utils/http_status';
+
+import resolvers from '~/ci_lint/graphql/resolvers';
+import { mockLintResponse } from '../mock_data';
+
+describe('~/ci_lint/graphql/resolvers', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('Mutation', () => {
+ describe('lintCI', () => {
+ const endpoint = '/ci/lint';
+
+ beforeEach(() => {
+ mock.onPost(endpoint).reply(httpStatus.OK, mockLintResponse);
+ });
+
+ it('resolves lint data with type names', async () => {
+ const result = resolvers.Mutation.lintCI(null, {
+ endpoint,
+ content: 'content',
+ dry_run: true,
+ });
+
+ await expect(result).resolves.toMatchSnapshot();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js
index cf7d69dcad3..b87c9f8413b 100644
--- a/spec/frontend/ci_lint/mock_data.js
+++ b/spec/frontend/ci_lint/mock_data.js
@@ -1,3 +1,37 @@
+export const mockLintResponse = {
+ valid: true,
+ errors: [],
+ warnings: [],
+ jobs: [
+ {
+ name: 'job_1',
+ stage: 'test',
+ before_script: ["echo 'before script 1'"],
+ script: ["echo 'script 1'"],
+ after_script: ["echo 'after script 1"],
+ tag_list: ['tag 1'],
+ environment: 'prd',
+ when: 'on_success',
+ allow_failure: false,
+ only: null,
+ except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
+ },
+ {
+ name: 'job_2',
+ stage: 'test',
+ before_script: ["echo 'before script 2'"],
+ script: ["echo 'script 2'"],
+ after_script: ["echo 'after script 2"],
+ tag_list: ['tag 2'],
+ environment: 'stg',
+ when: 'on_success',
+ allow_failure: true,
+ only: { refs: ['web', 'chat', 'pushes'] },
+ except: { refs: ['master@gitlab-org/gitlab', '/^release/.*$/@gitlab-org/gitlab'] },
+ },
+ ],
+};
+
export const mockJobs = [
{
name: 'job_1',
@@ -47,3 +81,14 @@ export const mockErrors = [
export const mockWarnings = [
'"jobs:multi_project_job may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings"',
];
+
+export const mockLintDataValid = {
+ data: {
+ lintCI: {
+ errors: [],
+ warnings: [],
+ valid: true,
+ jobs: mockJobs,
+ },
+ },
+};
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
deleted file mode 100644
index 93b185bd242..00000000000
--- a/spec/frontend/ci_variable_list/ci_variable_list/ajax_variable_list_spec.js
+++ /dev/null
@@ -1,203 +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,
- });
-
- 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
index 9508203e5c2..4a2e56c570d 100644
--- a/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
+++ b/spec/frontend/ci_variable_list/ci_variable_list/ci_variable_list_spec.js
@@ -1,5 +1,4 @@
import $ from 'jquery';
-import waitForPromises from 'helpers/wait_for_promises';
import VariableList from '~/ci_variable_list/ci_variable_list';
const HIDE_CLASS = 'hide';
@@ -7,7 +6,6 @@ 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;
@@ -113,92 +111,6 @@ describe('VariableList', () => {
});
});
- 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');
@@ -247,36 +159,4 @@ describe('VariableList', () => {
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/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap
index b6e89281fef..744ef318260 100644
--- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap
@@ -73,7 +73,7 @@ exports[`Applications Ingress application shows the correct warning message 1`]
exports[`Applications Knative application shows the correct description 1`] = `
<span
- data-testid="installedVia"
+ data-testid="installed-via"
>
installed via
<a
diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
index 15eeadcc8b8..de40e03b598 100644
--- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
+++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap
@@ -14,6 +14,8 @@ exports[`Remove cluster confirmation modal renders splitbutton with modal includ
>
<!---->
+ <!---->
+
<span
class="gl-new-dropdown-button-text"
>
diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js
index e0ccf36e868..5438f3053a8 100644
--- a/spec/frontend/clusters/components/applications_spec.js
+++ b/spec/frontend/clusters/components/applications_spec.js
@@ -429,7 +429,7 @@ describe('Applications', () => {
await wrapper.vm.$nextTick();
- expect(findByTestId('installedVia').element).toMatchSnapshot();
+ expect(findByTestId('installed-via').element).toMatchSnapshot();
});
it('emits saveKnativeDomain event when knative domain editor emits save event', () => {
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 62b751ec59b..62e527a2c5f 100644
--- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
+++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap
@@ -12,7 +12,7 @@ exports[`Code navigation popover component renders popover 1`] = `
<gl-tabs-stub
contentclass="gl-py-0"
- nav-class="gl-hidden"
+ navclass="gl-hidden"
theme="indigo"
>
<gl-tab-stub
diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
index 2600415fc9f..f9984091df0 100644
--- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js
@@ -16,7 +16,6 @@ describe('EksClusterConfigurationForm', () => {
let getters;
let state;
let rolesState;
- let regionsState;
let vpcsState;
let subnetsState;
let keyPairsState;
@@ -24,7 +23,6 @@ describe('EksClusterConfigurationForm', () => {
let instanceTypesState;
let vpcsActions;
let rolesActions;
- let regionsActions;
let subnetsActions;
let keyPairsActions;
let securityGroupsActions;
@@ -46,9 +44,6 @@ describe('EksClusterConfigurationForm', () => {
setNodeCount: jest.fn(),
setGitlabManagedCluster: jest.fn(),
};
- regionsActions = {
- fetchItems: jest.fn(),
- };
keyPairsActions = {
fetchItems: jest.fn(),
};
@@ -72,10 +67,6 @@ describe('EksClusterConfigurationForm', () => {
...clusterDropdownStoreState(),
...config.rolesState,
};
- regionsState = {
- ...clusterDropdownStoreState(),
- ...config.regionsState,
- };
vpcsState = {
...clusterDropdownStoreState(),
...config.vpcsState,
@@ -109,11 +100,6 @@ describe('EksClusterConfigurationForm', () => {
state: vpcsState,
actions: vpcsActions,
},
- regions: {
- namespaced: true,
- state: regionsState,
- actions: regionsActions,
- },
subnets: {
namespaced: true,
state: subnetsState,
@@ -189,7 +175,6 @@ describe('EksClusterConfigurationForm', () => {
const findClusterNameInput = () => vm.find('[id=eks-cluster-name]');
const findEnvironmentScopeInput = () => vm.find('[id=eks-environment-scope]');
const findKubernetesVersionDropdown = () => vm.find('[field-id="eks-kubernetes-version"]');
- const findRegionDropdown = () => vm.find('[field-id="eks-region"]');
const findKeyPairDropdown = () => vm.find('[field-id="eks-key-pair"]');
const findVpcDropdown = () => vm.find('[field-id="eks-vpc"]');
const findSubnetDropdown = () => vm.find('[field-id="eks-subnet"]');
@@ -200,13 +185,44 @@ describe('EksClusterConfigurationForm', () => {
const findGitlabManagedClusterCheckbox = () => vm.find(GlFormCheckbox);
describe('when mounted', () => {
- it('fetches available regions', () => {
- expect(regionsActions.fetchItems).toHaveBeenCalled();
- });
-
it('fetches available roles', () => {
expect(rolesActions.fetchItems).toHaveBeenCalled();
});
+
+ describe('when fetching vpcs and key pairs', () => {
+ const region = 'us-west-2';
+
+ beforeEach(() => {
+ createValidStateStore({ selectedRegion: region });
+ buildWrapper();
+ });
+
+ it('fetches available vpcs', () => {
+ expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
+ });
+
+ it('fetches available key pairs', () => {
+ expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
+ });
+
+ it('cleans selected vpc', () => {
+ expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null });
+ });
+
+ it('cleans selected key pair', () => {
+ expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null });
+ });
+
+ it('cleans selected subnet', () => {
+ expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
+ });
+
+ it('cleans selected security group', () => {
+ expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
+ securityGroup: null,
+ });
+ });
+ });
});
it('sets isLoadingRoles to RoleDropdown loading property', () => {
@@ -229,26 +245,6 @@ describe('EksClusterConfigurationForm', () => {
});
});
- it('sets isLoadingRegions to RegionDropdown loading property', () => {
- regionsState.isLoadingItems = true;
-
- return Vue.nextTick().then(() => {
- expect(findRegionDropdown().props('loading')).toBe(regionsState.isLoadingItems);
- });
- });
-
- it('sets regions to RegionDropdown regions property', () => {
- expect(findRegionDropdown().props('items')).toBe(regionsState.items);
- });
-
- it('sets loadingRegionsError to RegionDropdown error property', () => {
- regionsState.loadingItemsError = new Error();
-
- return Vue.nextTick().then(() => {
- expect(findRegionDropdown().props('hasErrors')).toEqual(true);
- });
- });
-
it('disables KeyPairDropdown when no region is selected', () => {
expect(findKeyPairDropdown().props('disabled')).toBe(true);
});
@@ -394,44 +390,6 @@ describe('EksClusterConfigurationForm', () => {
});
});
- describe('when region is selected', () => {
- const region = { name: 'us-west-2' };
-
- beforeEach(() => {
- findRegionDropdown().vm.$emit('input', region);
- });
-
- it('dispatches setRegion action', () => {
- expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region });
- });
-
- it('fetches available vpcs', () => {
- expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
- });
-
- it('fetches available key pairs', () => {
- expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region });
- });
-
- it('cleans selected vpc', () => {
- expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null });
- });
-
- it('cleans selected key pair', () => {
- expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null });
- });
-
- it('cleans selected subnet', () => {
- expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] });
- });
-
- it('cleans selected security group', () => {
- expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), {
- securityGroup: null,
- });
- });
- });
-
it('dispatches setClusterName when cluster name input changes', () => {
const clusterName = 'name';
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 0ef09b4b87e..03c22c570a8 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
@@ -3,12 +3,10 @@ import EC2 from 'aws-sdk/clients/ec2';
import {
setAWSConfig,
fetchRoles,
- fetchRegions,
fetchKeyPairs,
fetchVpcs,
fetchSubnets,
fetchSecurityGroups,
- DEFAULT_REGION,
} from '~/create_cluster/eks_cluster/services/aws_services_facade';
const mockListRolesPromise = jest.fn();
@@ -45,19 +43,17 @@ describe('awsServicesFacade', () => {
vpc = 'vpc-2';
});
- it('setAWSConfig configures AWS SDK with provided credentials and default region', () => {
+ it('setAWSConfig configures AWS SDK with provided credentials', () => {
const awsCredentials = {
accessKeyId: 'access-key',
secretAccessKey: 'secret-key',
sessionToken: 'session-token',
+ region,
};
setAWSConfig({ awsCredentials });
- expect(AWS.config).toEqual({
- ...awsCredentials,
- region: DEFAULT_REGION,
- });
+ expect(AWS.config).toEqual(awsCredentials);
});
describe('when fetchRoles succeeds', () => {
@@ -79,22 +75,6 @@ describe('awsServicesFacade', () => {
});
});
- describe('when fetchRegions succeeds', () => {
- let regions;
- let regionsOutput;
-
- beforeEach(() => {
- regions = [{ RegionName: 'east-1' }, { RegionName: 'west-2' }];
- regionsOutput = regions.map(({ RegionName: name }) => ({ name, value: name }));
-
- mockDescribeRegionsPromise.mockResolvedValueOnce({ Regions: regions });
- });
-
- it('return list of roles where each item has a name and value', () => {
- return expect(fetchRegions()).resolves.toEqual(regionsOutput);
- });
- });
-
describe('when fetchKeyPairs succeeds', () => {
let keyPairs;
let keyPairsOutput;
diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
index f929216689a..f12f300872a 100644
--- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
+++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js
@@ -23,6 +23,7 @@ import {
REQUEST_CREATE_CLUSTER,
CREATE_CLUSTER_ERROR,
} from '~/create_cluster/eks_cluster/store/mutation_types';
+import { DEFAULT_REGION } from '~/create_cluster/eks_cluster/constants';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
@@ -109,12 +110,13 @@ describe('EKS Cluster Store Actions', () => {
secretAccessKey: 'secret-key-id',
};
- describe('when request succeeds', () => {
+ describe('when request succeeds with default region', () => {
beforeEach(() => {
mock
.onPost(state.createRolePath, {
role_arn: payload.roleArn,
role_external_id: payload.externalId,
+ region: DEFAULT_REGION,
})
.reply(201, response);
});
@@ -125,7 +127,51 @@ describe('EKS Cluster Store Actions', () => {
payload,
state,
[],
- [{ type: 'requestCreateRole' }, { type: 'createRoleSuccess', payload: response }],
+ [
+ { type: 'requestCreateRole' },
+ {
+ type: 'createRoleSuccess',
+ payload: {
+ region: DEFAULT_REGION,
+ ...response,
+ },
+ },
+ ],
+ ));
+ });
+
+ describe('when request succeeds with custom region', () => {
+ const customRegion = 'custom-region';
+
+ beforeEach(() => {
+ mock
+ .onPost(state.createRolePath, {
+ role_arn: payload.roleArn,
+ role_external_id: payload.externalId,
+ region: customRegion,
+ })
+ .reply(201, response);
+ });
+
+ it('dispatches createRoleSuccess action', () =>
+ testAction(
+ actions.createRole,
+ {
+ selectedRegion: customRegion,
+ ...payload,
+ },
+ state,
+ [],
+ [
+ { type: 'requestCreateRole' },
+ {
+ type: 'createRoleSuccess',
+ payload: {
+ region: customRegion,
+ ...response,
+ },
+ },
+ ],
));
});
@@ -138,6 +184,7 @@ describe('EKS Cluster Store Actions', () => {
.onPost(state.createRolePath, {
role_arn: payload.roleArn,
role_external_id: payload.externalId,
+ region: DEFAULT_REGION,
})
.reply(400, error);
});
@@ -160,8 +207,14 @@ describe('EKS Cluster Store Actions', () => {
});
describe('createRoleSuccess', () => {
- it('commits createRoleSuccess mutation', () => {
- testAction(actions.createRoleSuccess, null, state, [{ type: CREATE_ROLE_SUCCESS }]);
+ it('sets region and commits createRoleSuccess mutation', () => {
+ testAction(
+ actions.createRoleSuccess,
+ { region },
+ state,
+ [{ type: CREATE_ROLE_SUCCESS }],
+ [{ type: 'setRegion', payload: { region } }],
+ );
});
});
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
index 9ecf6bf375b..9f28ddfd230 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js
@@ -4,6 +4,7 @@ import { GlButton, GlModal } from '@gitlab/ui';
import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue';
import createStore from '~/deploy_freeze/store';
+import { freezePeriodsFixture, timezoneDataFixture } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -11,8 +12,6 @@ localVue.use(Vuex);
describe('Deploy freeze modal', () => {
let wrapper;
let store;
- const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
beforeEach(() => {
store = createStore({
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
index d40df7de7d1..c29a0c0ca73 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js
@@ -4,6 +4,7 @@ import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_setti
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue';
import createStore from '~/deploy_freeze/store';
+import { timezoneDataFixture } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -11,7 +12,6 @@ localVue.use(Vuex);
describe('Deploy freeze settings', () => {
let wrapper;
let store;
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
beforeEach(() => {
store = createStore({
diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
index 383ffa90b22..8480705b5e3 100644
--- a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
+++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js
@@ -2,6 +2,7 @@ import Vuex from 'vuex';
import { createLocalVue, mount } from '@vue/test-utils';
import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue';
import createStore from '~/deploy_freeze/store';
+import { freezePeriodsFixture, timezoneDataFixture } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -9,7 +10,6 @@ localVue.use(Vuex);
describe('Deploy freeze table', () => {
let wrapper;
let store;
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
const createComponent = () => {
store = createStore({
@@ -50,7 +50,6 @@ describe('Deploy freeze table', () => {
});
it('displays data', () => {
- const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
store.state.freezePeriods = freezePeriodsFixture;
return wrapper.vm.$nextTick(() => {
diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
index d1219627ca7..2aa977dfa5a 100644
--- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
+++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js
@@ -1,9 +1,9 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlDropdownItem, GlDropdown } from '@gitlab/ui';
-import { secondsToHours } from '~/lib/utils/datetime_utility';
import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue';
import createStore from '~/deploy_freeze/store';
+import { findTzByName, formatTz, timezoneDataFixture } from '../helpers';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -11,12 +11,6 @@ localVue.use(Vuex);
describe('Deploy freeze timezone dropdown', () => {
let wrapper;
let store;
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
-
- const findTzByName = (identifier = '') =>
- timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase());
-
- const formatTz = ({ offset, name }) => `[UTC ${secondsToHours(offset)}] ${name}`;
const createComponent = (searchTerm, selectedTimezone) => {
store = createStore({
diff --git a/spec/frontend/deploy_freeze/helpers.js b/spec/frontend/deploy_freeze/helpers.js
new file mode 100644
index 00000000000..bfb84142662
--- /dev/null
+++ b/spec/frontend/deploy_freeze/helpers.js
@@ -0,0 +1,9 @@
+import { secondsToHours } from '~/lib/utils/datetime_utility';
+
+export const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
+export const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
+
+export const findTzByName = (identifier = '') =>
+ timezoneDataFixture.find(({ name }) => name.toLowerCase() === identifier.toLowerCase());
+
+export const formatTz = ({ offset, name }) => `[UTC ${secondsToHours(offset)}] ${name}`;
diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js
index 97f94cdbf5e..3c9d25c4f5c 100644
--- a/spec/frontend/deploy_freeze/store/actions_spec.js
+++ b/spec/frontend/deploy_freeze/store/actions_spec.js
@@ -6,6 +6,7 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import getInitialState from '~/deploy_freeze/store/state';
import * as actions from '~/deploy_freeze/store/actions';
import * as types from '~/deploy_freeze/store/mutation_types';
+import { freezePeriodsFixture, timezoneDataFixture } from '../helpers';
jest.mock('~/api.js');
jest.mock('~/flash.js');
@@ -13,8 +14,6 @@ jest.mock('~/flash.js');
describe('deploy freeze store actions', () => {
let mock;
let state;
- const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
beforeEach(() => {
mock = new MockAdapter(axios);
diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js
index 0453e037e15..7cb208f16b2 100644
--- a/spec/frontend/deploy_freeze/store/mutations_spec.js
+++ b/spec/frontend/deploy_freeze/store/mutations_spec.js
@@ -2,10 +2,10 @@ import state from '~/deploy_freeze/store/state';
import mutations from '~/deploy_freeze/store/mutations';
import * as types from '~/deploy_freeze/store/mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { findTzByName, formatTz, freezePeriodsFixture, timezoneDataFixture } from '../helpers';
describe('Deploy freeze mutations', () => {
let stateCopy;
- const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json');
beforeEach(() => {
stateCopy = state({
@@ -28,7 +28,6 @@ describe('Deploy freeze mutations', () => {
describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => {
it('should set freeze periods and format timezones from identifiers to names', () => {
const timezoneNames = ['Berlin', 'UTC', 'Eastern Time (US & Canada)'];
- const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json');
mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture);
@@ -43,9 +42,10 @@ describe('Deploy freeze mutations', () => {
describe('SET_SELECTED_TIMEZONE', () => {
it('should set the cron timezone', () => {
+ const selectedTz = findTzByName('Pacific Time (US & Canada)');
const timezone = {
- formattedTimezone: '[UTC -7] Pacific Time (US & Canada)',
- identifier: 'America/Los_Angeles',
+ formattedTimezone: formatTz(selectedTz),
+ identifier: selectedTz.identifier,
};
mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone);
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index 0b1cbd28274..d990c64c241 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -79,7 +79,7 @@ describe('Deploy keys key', () => {
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(
+ expect(wrapper.find('.deploy-project-label').attributes('title')).toBe(
'Write access allowed',
);
});
@@ -88,9 +88,7 @@ describe('Deploy keys key', () => {
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',
- );
+ expect(wrapper.find('.deploy-project-label').attributes('title')).toBe('Read access only');
});
it('shows expandable button if more than two projects', () => {
@@ -99,7 +97,7 @@ describe('Deploy keys key', () => {
expect(labels.length).toBe(2);
expect(labels.at(1).text()).toContain('others');
- expect(labels.at(1).attributes('data-original-title')).toContain('Expand');
+ expect(labels.at(1).attributes('title')).toContain('Expand');
});
it('expands all project labels after click', () => {
@@ -115,7 +113,7 @@ describe('Deploy keys key', () => {
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');
+ expect(labels.at(1).attributes('title')).not.toContain('Expand');
});
});
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
deleted file mode 100644
index 0679b485f77..00000000000
--- a/spec/frontend/design_management/components/__snapshots__/design_scaler_spec.js.snap
+++ /dev/null
@@ -1,115 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
-<div
- class="design-scaler btn-group"
- role="group"
->
- <button
- class="btn"
- disabled="disabled"
- >
- <span
- class="gl-display-flex gl-justify-content-center gl-align-items-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="gl-display-flex gl-justify-content-center gl-align-items-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="gl-display-flex gl-justify-content-center gl-align-items-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/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js
index 4ef067e3f5e..f4fd4c70dfc 100644
--- a/spec/frontend/design_management/components/design_overlay_spec.js
+++ b/spec/frontend/design_management/components/design_overlay_spec.js
@@ -243,11 +243,11 @@ describe('Design overlay component', () => {
});
});
- describe('without [adminNote] permission', () => {
+ describe('without [repositionNote] permission', () => {
const mockNoteNotAuthorised = {
...notes[0],
userPermissions: {
- adminNote: false,
+ repositionNote: false,
},
};
@@ -412,18 +412,18 @@ describe('Design overlay component', () => {
describe('canMoveNote', () => {
it.each`
- adminNotePermission | canMoveNoteResult
- ${true} | ${true}
- ${false} | ${false}
- ${undefined} | ${false}
+ repositionNotePermission | canMoveNoteResult
+ ${true} | ${true}
+ ${false} | ${false}
+ ${undefined} | ${false}
`(
- 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]',
- ({ adminNotePermission, canMoveNoteResult }) => {
+ 'returns [$canMoveNoteResult] when [repositionNote permission] is [$repositionNotePermission]',
+ ({ repositionNotePermission, canMoveNoteResult }) => {
createComponent();
const note = {
userPermissions: {
- adminNote: adminNotePermission,
+ repositionNote: repositionNotePermission,
},
};
expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult);
diff --git a/spec/frontend/design_management/components/design_scaler_spec.js b/spec/frontend/design_management/components/design_scaler_spec.js
index b06d2f924df..290ec3a18e3 100644
--- a/spec/frontend/design_management/components/design_scaler_spec.js
+++ b/spec/frontend/design_management/components/design_scaler_spec.js
@@ -1,67 +1,93 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
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);
- }
+ const getButtons = () => wrapper.findAll(GlButton);
+ const getDecreaseScaleButton = () => getButtons().at(0);
+ const getResetScaleButton = () => getButtons().at(1);
+ const getIncreaseScaleButton = () => getButtons().at(2);
- afterEach(() => {
- wrapper.destroy();
- });
+ const setScale = scale => wrapper.vm.setScale(scale);
- const getButton = type => {
- const buttonTypeOrder = ['minus', 'reset', 'plus'];
- const buttons = wrapper.findAll('button');
- return buttons.at(buttonTypeOrder.indexOf(type));
+ const createComponent = () => {
+ wrapper = shallowMount(DesignScaler);
};
- it('emits @scale event when "plus" button clicked', () => {
+ beforeEach(() => {
createComponent();
+ });
- getButton('plus').trigger('click');
- expect(wrapper.emitted('scale')).toEqual([[1.2]]);
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
});
- 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]]);
+ describe('when `scale` value is greater than 1', () => {
+ beforeEach(async () => {
+ setScale(1.6);
+ await wrapper.vm.$nextTick();
});
- });
- it('emits @scale event when "minus" button clicked (scale > 1)', () => {
- createComponent({}, { scale: 1.6 });
+ it('emits @scale event when "reset" button clicked', () => {
+ getResetScaleButton().vm.$emit('click');
+ expect(wrapper.emitted('scale')[1]).toEqual([1]);
+ });
- return wrapper.vm.$nextTick().then(() => {
- getButton('minus').trigger('click');
- expect(wrapper.emitted('scale')).toEqual([[1.4]]);
+ it('emits @scale event when "decrement" button clicked', async () => {
+ getDecreaseScaleButton().vm.$emit('click');
+ expect(wrapper.emitted('scale')[1]).toEqual([1.4]);
});
- });
- it('minus and reset buttons are disabled when scale === 1', () => {
- createComponent();
+ it('enables the "reset" button', () => {
+ const resetButton = getResetScaleButton();
+
+ expect(resetButton.exists()).toBe(true);
+ expect(resetButton.props('disabled')).toBe(false);
+ });
+
+ it('enables the "decrement" button', () => {
+ const decrementButton = getDecreaseScaleButton();
- expect(wrapper.element).toMatchSnapshot();
+ expect(decrementButton.exists()).toBe(true);
+ expect(decrementButton.props('disabled')).toBe(false);
+ });
+ });
+
+ it('emits @scale event when "plus" button clicked', () => {
+ getIncreaseScaleButton().vm.$emit('click');
+ expect(wrapper.emitted('scale')).toEqual([[1.2]]);
});
- it('minus and reset buttons are enabled when scale > 1', () => {
- createComponent({}, { scale: 1.2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
+ describe('when `scale` value is 1', () => {
+ it('disables the "reset" button', () => {
+ const resetButton = getResetScaleButton();
+
+ expect(resetButton.exists()).toBe(true);
+ expect(resetButton.props('disabled')).toBe(true);
+ });
+
+ it('disables the "decrement" button', () => {
+ const decrementButton = getDecreaseScaleButton();
+
+ expect(decrementButton.exists()).toBe(true);
+ expect(decrementButton.props('disabled')).toBe(true);
});
});
- it('plus button is disabled when scale === 2', () => {
- createComponent({}, { scale: 2 });
- return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
+ describe('when `scale` value is 2 (maximum)', () => {
+ beforeEach(async () => {
+ setScale(2);
+ await wrapper.vm.$nextTick();
+ });
+
+ it('disables the "increment" button', () => {
+ const incrementButton = getIncreaseScaleButton();
+
+ expect(incrementButton.exists()).toBe(true);
+ expect(incrementButton.props('disabled')).toBe(true);
});
});
});
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
index 723ac0491a7..e2ad4c68bea 100644
--- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap
@@ -46,6 +46,7 @@ exports[`Design management toolbar component renders design and updated data 1`]
href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d"
icon="download"
size="medium"
+ title="Download design"
variant="default"
/>
@@ -57,6 +58,7 @@ exports[`Design management toolbar component renders design and updated data 1`]
buttonvariant="warning"
class="gl-ml-3"
hasselecteddesigns="true"
+ title="Archive design"
/>
</header>
`;
diff --git a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
index eaa7460ae15..2f857247303 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
+++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap
@@ -14,41 +14,7 @@ exports[`Design management upload button component renders inverted upload desig
>
Upload designs
-
- <!---->
- </gl-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-button-stub
- buttontextclasses=""
- category="primary"
- disabled="true"
- icon=""
- size="small"
- title="Adding a design with the same filename replaces the file in a new version."
- variant="default"
- >
-
- Upload designs
-
- <gl-loading-icon-stub
- class="ml-1"
- color="orange"
- inline="true"
- label="Loading"
- size="sm"
- />
+
</gl-button-stub>
<input
@@ -73,8 +39,7 @@ exports[`Design management upload button component renders upload design button
>
Upload designs
-
- <!---->
+
</gl-button-stub>
<input
diff --git a/spec/frontend/design_management/components/upload/button_spec.js b/spec/frontend/design_management/components/upload/button_spec.js
index c0a9693dc37..ea738496ad6 100644
--- a/spec/frontend/design_management/components/upload/button_spec.js
+++ b/spec/frontend/design_management/components/upload/button_spec.js
@@ -1,10 +1,11 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
import UploadButton from '~/design_management/components/upload/button.vue';
describe('Design management upload button component', () => {
let wrapper;
- function createComponent(isSaving = false, isInverted = false) {
+ function createComponent({ isSaving = false, isInverted = false } = {}) {
wrapper = shallowMount(UploadButton, {
propsData: {
isSaving,
@@ -24,15 +25,19 @@ describe('Design management upload button component', () => {
});
it('renders inverted upload design button', () => {
- createComponent(false, true);
+ createComponent({ isInverted: true });
expect(wrapper.element).toMatchSnapshot();
});
- it('renders loading icon', () => {
- createComponent(true);
+ describe('when `isSaving` prop is `true`', () => {
+ it('Button `loading` prop is `true`', () => {
+ createComponent({ isSaving: true });
- expect(wrapper.element).toMatchSnapshot();
+ const button = wrapper.find(GlButton);
+ expect(button.exists()).toBe(true);
+ expect(button.props('loading')).toBe(true);
+ });
});
describe('onFileUploadChange', () => {
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index 5e41210221b..e53ad2e6afe 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -1,13 +1,18 @@
export const designListQueryResponse = {
data: {
project: {
+ __typename: 'Project',
id: '1',
issue: {
+ __typename: 'Issue',
designCollection: {
+ __typename: 'DesignCollection',
copyState: 'READY',
designs: {
+ __typename: 'DesignConnection',
nodes: [
{
+ __typename: 'Design',
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
@@ -15,10 +20,12 @@ export const designListQueryResponse = {
image: 'image-1',
imageV432x230: 'image-1',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
{
+ __typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
@@ -26,10 +33,12 @@ export const designListQueryResponse = {
image: 'image-2',
imageV432x230: 'image-2',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
{
+ __typename: 'Design',
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
@@ -37,12 +46,14 @@ export const designListQueryResponse = {
image: 'image-3',
imageV432x230: 'image-3',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
],
},
versions: {
+ __typename: 'DesignVersion',
nodes: [],
},
},
@@ -82,9 +93,11 @@ export const designUploadMutationUpdatedResponse = {
export const permissionsQueryResponse = {
data: {
project: {
+ __typename: 'Project',
id: '1',
issue: {
- userPermissions: { createDesign: true },
+ __typename: 'Issue',
+ userPermissions: { __typename: 'UserPermissions', createDesign: true },
},
},
},
@@ -92,6 +105,7 @@ export const permissionsQueryResponse = {
export const reorderedDesigns = [
{
+ __typename: 'Design',
id: '2',
event: 'NONE',
filename: 'fox_2.jpg',
@@ -99,10 +113,12 @@ export const reorderedDesigns = [
image: 'image-2',
imageV432x230: 'image-2',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
{
+ __typename: 'Design',
id: '1',
event: 'NONE',
filename: 'fox_1.jpg',
@@ -110,10 +126,12 @@ export const reorderedDesigns = [
image: 'image-1',
imageV432x230: 'image-1',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
{
+ __typename: 'Design',
id: '3',
event: 'NONE',
filename: 'fox_3.jpg',
@@ -121,6 +139,7 @@ export const reorderedDesigns = [
image: 'image-3',
imageV432x230: 'image-3',
currentUserTodos: {
+ __typename: 'ToDo',
nodes: [],
},
},
@@ -130,7 +149,9 @@ export const moveDesignMutationResponse = {
data: {
designManagementMove: {
designCollection: {
+ __typename: 'DesignCollection',
designs: {
+ __typename: 'DesignConnection',
nodes: [...reorderedDesigns],
},
},
diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js
index fbf9a2fdcc1..0e59ef29f8f 100644
--- a/spec/frontend/design_management/mock_data/discussion.js
+++ b/spec/frontend/design_management/mock_data/discussion.js
@@ -18,7 +18,7 @@ export default {
},
createdAt: '2020-05-08T07:10:45Z',
userPermissions: {
- adminNote: true,
+ repositionNote: true,
},
resolved: false,
},
diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
index 2d29b79e31c..abd455ae750 100644
--- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap
@@ -1,240 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Design management index page designs does not render toolbar when there is no permission 1`] = `
-<div
- class="gl-mt-5"
- data-testid="designs-root"
->
- <!---->
-
- <div
- class="gl-mt-6"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3"
- data-testid="design-dropzone-wrapper"
- >
- <design-dropzone-stub
- class="design-list-item design-list-item-new"
- data-qa-selector="design_dropzone_content"
- hasdesigns="true"
- />
- </li>
- <li
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- 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-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- 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-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- 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
- class="gl-mt-5"
- data-testid="designs-root"
->
- <header
- class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex"
- >
- <div
- class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"
- >
- <div>
- <span
- class="gl-font-weight-bold gl-mr-3"
- >
- Designs
- </span>
-
- <design-version-dropdown-stub />
- </div>
-
- <div
- class="qa-selector-toolbar gl-display-flex gl-align-items-center"
- >
- <gl-button-stub
- buttontextclasses=""
- category="primary"
- class="gl-mr-4 js-select-all"
- icon=""
- size="small"
- variant="link"
- >
- Select all
-
- </gl-button-stub>
-
- <div>
- <delete-button-stub
- buttoncategory="secondary"
- buttonclass="gl-mr-3"
- buttonsize="small"
- buttonvariant="warning"
- data-qa-selector="archive_button"
- >
-
- Archive selected
-
- </delete-button-stub>
- </div>
-
- <upload-button-stub />
- </div>
- </div>
- </header>
-
- <div
- class="gl-mt-6"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3"
- data-testid="design-dropzone-wrapper"
- >
- <design-dropzone-stub
- class="design-list-item design-list-item-new"
- data-qa-selector="design_dropzone_content"
- hasdesigns="true"
- />
- </li>
- <li
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- event="NONE"
- filename="design-1-name"
- id="design-1"
- image="design-1-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- data-qa-design="design-1-name"
- data-qa-selector="design_checkbox"
- type="checkbox"
- />
- </li>
- <li
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- event="NONE"
- filename="design-2-name"
- id="design-2"
- image="design-2-image"
- notescount="1"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- data-qa-design="design-2-name"
- data-qa-selector="design_checkbox"
- type="checkbox"
- />
- </li>
- <li
- class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile"
- >
- <design-dropzone-stub
- hasdesigns="true"
- >
- <design-stub
- class="gl-bg-white"
- event="NONE"
- filename="design-3-name"
- id="design-3"
- image="design-3-image"
- notescount="0"
- />
- </design-dropzone-stub>
-
- <input
- class="design-checkbox"
- data-qa-design="design-3-name"
- data-qa-selector="design_checkbox"
- type="checkbox"
- />
- </li>
- </ol>
- </div>
-
- <router-view-stub
- name="default"
- />
-</div>
-`;
-
exports[`Design management index page designs renders error 1`] = `
<div
class="gl-mt-5"
@@ -277,7 +42,7 @@ exports[`Design management index page designs renders loading icon 1`] = `
class="gl-mt-6"
>
<gl-loading-icon-stub
- color="orange"
+ color="dark"
label="Loading"
size="md"
/>
@@ -288,34 +53,3 @@ exports[`Design management index page designs renders loading icon 1`] = `
/>
</div>
`;
-
-exports[`Design management index page when has no designs renders design dropzone 1`] = `
-<div
- class="gl-mt-5"
- data-testid="designs-root"
->
- <!---->
-
- <div
- class="gl-mt-6"
- >
- <ol
- class="list-unstyled row"
- >
- <li
- class="col-12"
- data-testid="design-dropzone-wrapper"
- >
- <design-dropzone-stub
- class=""
- data-qa-selector="design_dropzone_content"
- />
- </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
index 3d6c2561ff6..03ae77d4977 100644
--- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap
@@ -136,7 +136,7 @@ exports[`Design management design index page sets loading state 1`] = `
>
<gl-loading-icon-stub
class="gl-align-self-center"
- color="orange"
+ color="dark"
label="Loading"
size="xl"
/>
diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js
index d9f7146d258..88892bb1878 100644
--- a/spec/frontend/design_management/pages/design/index_spec.js
+++ b/spec/frontend/design_management/pages/design/index_spec.js
@@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import { GlAlert } from '@gitlab/ui';
import { ApolloMutation } from 'vue-apollo';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import DesignIndex from '~/design_management/pages/design/index.vue';
import DesignSidebar from '~/design_management/components/design_sidebar.vue';
import DesignPresentation from '~/design_management/components/design_presentation.vue';
@@ -24,7 +24,13 @@ import mockAllVersions from '../../mock_data/all_versions';
jest.mock('~/flash');
const focusInput = jest.fn();
-
+const mutate = jest.fn().mockResolvedValue();
+const mockPageLayoutElement = {
+ classList: {
+ add: jest.fn(),
+ remove: jest.fn(),
+ },
+};
const DesignReplyForm = {
template: '<div><textarea ref="textarea"></textarea></div>',
methods: {
@@ -37,6 +43,32 @@ const mockDesignNoDiscussions = {
nodes: [],
},
};
+const newComment = 'new comment';
+const annotationCoordinates = {
+ x: 10,
+ y: 10,
+ width: 100,
+ height: 100,
+};
+const createDiscussionMutationVariables = {
+ mutation: createImageDiffNoteMutation,
+ update: expect.anything(),
+ variables: {
+ input: {
+ body: newComment,
+ noteableId: design.id,
+ position: {
+ headSha: 'headSha',
+ baseSha: 'baseSha',
+ startSha: 'startSha',
+ paths: {
+ newPath: 'full-design-path',
+ },
+ ...annotationCoordinates,
+ },
+ },
+ },
+};
const localVue = createLocalVue();
localVue.use(VueRouter);
@@ -45,35 +77,6 @@ describe('Design management design index page', () => {
let wrapper;
let router;
- const newComment = 'new comment';
- const annotationCoordinates = {
- x: 10,
- y: 10,
- width: 100,
- height: 100,
- };
- const createDiscussionMutationVariables = {
- mutation: createImageDiffNoteMutation,
- update: expect.anything(),
- variables: {
- input: {
- body: newComment,
- noteableId: design.id,
- position: {
- headSha: 'headSha',
- baseSha: 'baseSha',
- startSha: 'startSha',
- paths: {
- newPath: 'full-design-path',
- },
- ...annotationCoordinates,
- },
- },
- },
- };
-
- const mutate = jest.fn().mockResolvedValue();
-
const findDiscussionForm = () => wrapper.find(DesignReplyForm);
const findSidebar = () => wrapper.find(DesignSidebar);
const findDesignPresentation = () => wrapper.find(DesignPresentation);
@@ -122,19 +125,44 @@ describe('Design management design index page', () => {
wrapper.destroy();
});
- describe('when navigating', () => {
- it('applies fullscreen layout', () => {
- const mockEl = {
- classList: {
- add: jest.fn(),
- remove: jest.fn(),
- },
- };
- jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl);
+ describe('when navigating to component', () => {
+ it('applies fullscreen layout class', () => {
+ jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement);
createComponent({ loading: true });
- expect(mockEl.classList.add).toHaveBeenCalledTimes(1);
- expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
+ expect(mockPageLayoutElement.classList.add).toHaveBeenCalledTimes(1);
+ expect(mockPageLayoutElement.classList.add).toHaveBeenCalledWith(
+ ...DESIGN_DETAIL_LAYOUT_CLASSLIST,
+ );
+ });
+ });
+
+ describe('when navigating within the component', () => {
+ it('`scale` prop of DesignPresentation component is 1', async () => {
+ jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement);
+ createComponent({ loading: false }, { data: { design, scale: 2 } });
+
+ await wrapper.vm.$nextTick();
+ expect(findDesignPresentation().props('scale')).toBe(2);
+
+ DesignIndex.beforeRouteUpdate.call(wrapper.vm, {}, {}, jest.fn());
+ await wrapper.vm.$nextTick();
+
+ expect(findDesignPresentation().props('scale')).toBe(1);
+ });
+ });
+
+ describe('when navigating away from component', () => {
+ it('removes fullscreen layout class', async () => {
+ jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageLayoutElement);
+ createComponent({ loading: true });
+
+ wrapper.vm.$options.beforeRouteLeave[0].call(wrapper.vm, {}, {}, jest.fn());
+
+ expect(mockPageLayoutElement.classList.remove).toHaveBeenCalledTimes(1);
+ expect(mockPageLayoutElement.classList.remove).toHaveBeenCalledWith(
+ ...DESIGN_DETAIL_LAYOUT_CLASSLIST,
+ );
});
});
@@ -267,7 +295,7 @@ describe('Design management design index page', () => {
wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false });
return wrapper.vm.$nextTick().then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR);
+ expect(createFlash).toHaveBeenCalledWith({ message: DESIGN_NOT_FOUND_ERROR });
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
});
@@ -288,7 +316,7 @@ describe('Design management design index page', () => {
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(createFlash).toHaveBeenCalledWith({ message: DESIGN_VERSION_NOT_EXIST_ERROR });
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME });
});
diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js
index 27a91b11448..05238efd761 100644
--- a/spec/frontend/design_management/pages/index_spec.js
+++ b/spec/frontend/design_management/pages/index_spec.js
@@ -5,10 +5,12 @@ import VueRouter from 'vue-router';
import { GlEmptyState } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { mockTracking, unmockTracking } from 'helpers/tracking_helper';
+import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
+import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import Index from '~/design_management/pages/index.vue';
import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql';
import DesignDestroyer from '~/design_management/components/design_destroyer.vue';
-import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
+import DesignDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
import DeleteButton from '~/design_management/components/delete_button.vue';
import Design from '~/design_management/components/list/item.vue';
import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants';
@@ -16,10 +18,9 @@ import {
EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE,
EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE,
} from '~/design_management/utils/error_messages';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
import createRouter from '~/design_management/router';
import * as utils from '~/design_management/utils/design_management_utils';
-import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants';
import {
designListQueryResponse,
designUploadMutationCreatedResponse,
@@ -29,8 +30,6 @@ import {
reorderedDesigns,
moveDesignMutationResponseWithErrors,
} from '../mock_data/apollo_mock';
-import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql';
-import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql';
import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql';
import { DESIGN_TRACKING_PAGE_NAME } from '~/design_management/utils/tracking';
@@ -106,6 +105,8 @@ describe('Design management index page', () => {
const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]');
const findDesigns = () => wrapper.findAll(Design);
const draggableAttributes = () => wrapper.find(VueDraggable).vm.$attrs;
+ const findDesignUploadButton = () => wrapper.find('[data-testid="design-upload-button"]');
+ const findDesignToolbarWrapper = () => wrapper.find('[data-testid="design-toolbar-wrapper"]');
async function moveDesigns(localWrapper) {
await jest.runOnlyPendingTimers();
@@ -215,13 +216,17 @@ describe('Design management index page', () => {
it('renders designs list and header with upload button', () => {
createComponent({ allVersions: [mockVersion] });
- expect(wrapper.element).toMatchSnapshot();
+ expect(findDesignsWrapper().exists()).toBe(true);
+ expect(findDesigns().length).toBe(3);
+ expect(findDesignToolbarWrapper().exists()).toBe(true);
+ expect(findDesignUploadButton().exists()).toBe(true);
});
it('does not render toolbar when there is no permission', () => {
createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false });
- expect(wrapper.element).toMatchSnapshot();
+ expect(findDesignToolbarWrapper().exists()).toBe(false);
+ expect(findDesignUploadButton().exists()).toBe(false);
});
it('has correct classes applied to design dropzone', () => {
@@ -248,7 +253,7 @@ describe('Design management index page', () => {
it('renders design dropzone', () =>
wrapper.vm.$nextTick().then(() => {
- expect(wrapper.element).toMatchSnapshot();
+ expect(findDropzone().exists()).toBe(true);
}));
it('has correct classes applied to design dropzone', () => {
@@ -444,10 +449,10 @@ describe('Design management index page', () => {
return uploadDesign.then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(
- 'Upload skipped. test.jpg did not change.',
- 'warning',
- );
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Upload skipped. test.jpg did not change.',
+ types: 'warning',
+ });
});
});
@@ -483,7 +488,7 @@ describe('Design management index page', () => {
designDropzone.vm.$emit('change', eventPayload);
expect(createFlash).toHaveBeenCalledTimes(1);
- expect(createFlash).toHaveBeenCalledWith(message);
+ expect(createFlash).toHaveBeenCalledWith({ message });
});
});
@@ -682,13 +687,6 @@ describe('Design management index page', () => {
});
describe('when navigating', () => {
- it('ensures fullscreen layout is not applied', () => {
- createComponent({ loading: true });
-
- expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1);
- expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST);
- });
-
it('should trigger a scrollIntoView method if designs route is detected', () => {
router.replace({
path: '/designs',
@@ -755,7 +753,7 @@ describe('Design management index page', () => {
await wrapper.vm.$nextTick();
- expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem');
+ expect(createFlash).toHaveBeenCalledWith({ message: 'Houston, we have a problem' });
});
it('displays flash if mutation had a non-recoverable error', async () => {
@@ -769,9 +767,9 @@ describe('Design management index page', () => {
await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises)
await wrapper.vm.$nextTick(); // kick off the DOM update for flash
- expect(createFlash).toHaveBeenCalledWith(
- 'Something went wrong when reordering designs. Please try again',
- );
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'Something went wrong when reordering designs. Please try again',
+ });
});
});
});
diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js
index 6c859e8c3e8..2fb08c3ef05 100644
--- a/spec/frontend/design_management/utils/cache_update_spec.js
+++ b/spec/frontend/design_management/utils/cache_update_spec.js
@@ -3,7 +3,7 @@ import {
updateStoreAfterDesignsDelete,
updateStoreAfterAddImageDiffNote,
updateStoreAfterUploadDesign,
- updateStoreAfterUpdateImageDiffNote,
+ updateStoreAfterRepositionImageDiffNote,
} from '~/design_management/utils/cache_update';
import {
designDeletionError,
@@ -11,7 +11,7 @@ import {
UPDATE_IMAGE_DIFF_NOTE_ERROR,
} from '~/design_management/utils/error_messages';
import design from '../mock_data/design';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import createFlash from '~/flash';
jest.mock('~/flash.js');
@@ -26,16 +26,16 @@ describe('Design Management cache update', () => {
describe('error handling', () => {
it.each`
- fnName | subject | errorMessage | extraArgs
- ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
- ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
- ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
- ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ fnName | subject | errorMessage | extraArgs
+ ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]}
+ ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]}
+ ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]}
+ ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterRepositionImageDiffNote} | ${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);
+ expect(createFlash).toHaveBeenCalledWith({ message: 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
index 232cfa2f4ca..368448ead10 100644
--- a/spec/frontend/design_management/utils/design_management_utils_spec.js
+++ b/spec/frontend/design_management/utils/design_management_utils_spec.js
@@ -3,7 +3,7 @@ import {
extractDiscussions,
findVersionId,
designUploadOptimisticResponse,
- updateImageDiffNoteOptimisticResponse,
+ repositionImageDiffNoteOptimisticResponse,
isValidDesignFile,
extractDesign,
extractDesignNoteId,
@@ -112,7 +112,7 @@ describe('optimistic responses', () => {
expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse);
});
- it('correctly generated for updateImageDiffNoteOptimisticResponse', () => {
+ it('correctly generated for repositionImageDiffNoteOptimisticResponse', () => {
const mockNote = {
id: 'test-note-id',
};
@@ -126,8 +126,8 @@ describe('optimistic responses', () => {
const expectedResponse = {
__typename: 'Mutation',
- updateImageDiffNote: {
- __typename: 'UpdateImageDiffNotePayload',
+ repositionImageDiffNote: {
+ __typename: 'RepositionImageDiffNotePayload',
note: {
...mockNote,
position: mockPosition,
@@ -135,7 +135,7 @@ describe('optimistic responses', () => {
errors: [],
},
};
- expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual(
+ expect(repositionImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual(
expectedResponse,
);
});
diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js
index 86560470ada..225710eab63 100644
--- a/spec/frontend/diffs/components/app_spec.js
+++ b/spec/frontend/diffs/components/app_spec.js
@@ -697,7 +697,7 @@ describe('diffs/components/app', () => {
});
describe('collapsed files', () => {
- it('should render the collapsed files warning if there are any collapsed files', () => {
+ it('should render the collapsed files warning if there are any automatically collapsed files', () => {
createComponent({}, ({ state }) => {
state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
});
@@ -705,16 +705,14 @@ describe('diffs/components/app', () => {
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
});
- it('should not render the collapsed files warning if the user has dismissed the alert already', async () => {
+ it('should not render the collapsed files warning if there are no automatically collapsed files', () => {
createComponent({}, ({ state }) => {
- state.diffs.diffFiles = [{ viewer: { automaticallyCollapsed: true } }];
+ state.diffs.diffFiles = [
+ { viewer: { automaticallyCollapsed: false, manuallyCollapsed: true } },
+ { viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } },
+ ];
});
- expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true);
-
- wrapper.vm.collapsedWarningDismissed = true;
- await wrapper.vm.$nextTick();
-
expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false);
});
});
diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
index 7bbffb7a1cd..75e76d88b6b 100644
--- a/spec/frontend/diffs/components/collapsed_files_warning_spec.js
+++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js
@@ -2,7 +2,8 @@ import Vuex from 'vuex';
import { shallowMount, mount, createLocalVue } from '@vue/test-utils';
import createStore from '~/diffs/store/modules';
import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue';
-import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants';
+import { CENTERED_LIMITED_CONTAINER_CLASSES, EVT_EXPAND_ALL_FILES } from '~/diffs/constants';
+import eventHub from '~/diffs/event_hub';
const propsData = {
limited: true,
@@ -76,13 +77,13 @@ describe('CollapsedFilesWarning', () => {
expect(wrapper.find('[data-testid="root"]').exists()).toBe(false);
});
- it('triggers the expandAllFiles action when the alert action button is clicked', () => {
+ it(`emits the \`${EVT_EXPAND_ALL_FILES}\` event when the alert action button is clicked`, () => {
createComponent({}, { full: true });
- jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined);
+ jest.spyOn(eventHub, '$emit');
getAlertActionButton().vm.$emit('click');
- expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/expandAllFiles', undefined);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_EXPAND_ALL_FILES);
});
});
diff --git a/spec/frontend/diffs/components/diff_comment_cell_spec.js b/spec/frontend/diffs/components/diff_comment_cell_spec.js
new file mode 100644
index 00000000000..d6b68fc52d7
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_comment_cell_spec.js
@@ -0,0 +1,43 @@
+import { shallowMount } from '@vue/test-utils';
+import DiffCommentCell from '~/diffs/components/diff_comment_cell.vue';
+import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
+import DiffDiscussionReply from '~/diffs/components/diff_discussion_reply.vue';
+
+describe('DiffCommentCell', () => {
+ const createWrapper = (props = {}) => {
+ const { renderDiscussion, ...otherProps } = props;
+ const line = {
+ discussions: [],
+ renderDiscussion,
+ };
+ const diffFileHash = 'abc';
+
+ return shallowMount(DiffCommentCell, {
+ propsData: { line, diffFileHash, ...otherProps },
+ });
+ };
+
+ it('renders discussions if line has discussions', () => {
+ const wrapper = createWrapper({ renderDiscussion: true });
+
+ expect(wrapper.find(DiffDiscussions).exists()).toBe(true);
+ });
+
+ it('does not render discussions if line has no discussions', () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.find(DiffDiscussions).exists()).toBe(false);
+ });
+
+ it('renders discussion reply if line has no draft', () => {
+ const wrapper = createWrapper();
+
+ expect(wrapper.find(DiffDiscussionReply).exists()).toBe(true);
+ });
+
+ it('does not render discussion reply if line has draft', () => {
+ const wrapper = createWrapper({ hasDraft: true });
+
+ expect(wrapper.find(DiffDiscussionReply).exists()).toBe(false);
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js
index 6d0120d888e..e3a6f7f16a9 100644
--- a/spec/frontend/diffs/components/diff_content_spec.js
+++ b/spec/frontend/diffs/components/diff_content_spec.js
@@ -12,6 +12,8 @@ import DiffDiscussions from '~/diffs/components/diff_discussions.vue';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import diffFileMockData from '../mock_data/diff_file';
import { diffViewerModes } from '~/ide/constants';
+import { diffLines } from '~/diffs/store/getters';
+import DiffView from '~/diffs/components/diff_view.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -33,7 +35,7 @@ describe('DiffContent', () => {
diffFile: JSON.parse(JSON.stringify(diffFileMockData)),
};
- const createComponent = ({ props, state } = {}) => {
+ const createComponent = ({ props, state, provide } = {}) => {
const fakeStore = new Vuex.Store({
getters: {
getNoteableData() {
@@ -55,6 +57,10 @@ describe('DiffContent', () => {
namespaced: true,
getters: {
draftsForFile: () => () => true,
+ draftForLine: () => () => true,
+ shouldRenderDraftRow: () => () => true,
+ hasParallelDraftLeft: () => () => true,
+ hasParallelDraftRight: () => () => true,
},
},
diffs: {
@@ -68,6 +74,7 @@ describe('DiffContent', () => {
isInlineView: isInlineViewGetterMock,
isParallelView: isParallelViewGetterMock,
getCommentFormForDiffFile: getCommentFormForDiffFileGetterMock,
+ diffLines,
},
actions: {
saveDiffDiscussion: saveDiffDiscussionMock,
@@ -77,6 +84,8 @@ describe('DiffContent', () => {
},
});
+ const glFeatures = provide ? { ...provide.glFeatures } : {};
+
wrapper = shallowMount(DiffContentComponent, {
propsData: {
...defaultProps,
@@ -84,6 +93,7 @@ describe('DiffContent', () => {
},
localVue,
store: fakeStore,
+ provide: { glFeatures },
});
};
@@ -112,6 +122,16 @@ describe('DiffContent', () => {
expect(wrapper.find(ParallelDiffView).exists()).toBe(true);
});
+ it('should render diff view if `unifiedDiffLines` & `unifiedDiffComponents` are true', () => {
+ isParallelViewGetterMock.mockReturnValue(true);
+ createComponent({
+ props: { diffFile: textDiffFile },
+ provide: { glFeatures: { unifiedDiffLines: true, unifiedDiffComponents: true } },
+ });
+
+ expect(wrapper.find(DiffView).exists()).toBe(true);
+ });
+
it('renders rendering more lines loading icon', () => {
createComponent({ props: { diffFile: { ...textDiffFile, renderingLines: true } } });
diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js
index a04486fc5c7..1b41456f2f5 100644
--- a/spec/frontend/diffs/components/diff_file_header_spec.js
+++ b/spec/frontend/diffs/components/diff_file_header_spec.js
@@ -1,8 +1,12 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { cloneDeep } from 'lodash';
+
+import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
+
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
import diffDiscussionsMockData from '../mock_data/diff_discussions';
import { truncateSha } from '~/lib/utils/text_utility';
import { diffViewerModes } from '~/ide/constants';
@@ -136,9 +140,25 @@ describe('DiffFileHeader component', () => {
});
});
- it('displays a copy to clipboard button', () => {
- createComponent();
- expect(wrapper.find(ClipboardButton).exists()).toBe(true);
+ describe('copy to clipboard', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays a copy to clipboard button', () => {
+ expect(wrapper.find(ClipboardButton).exists()).toBe(true);
+ });
+
+ it('triggers the copy to clipboard tracking event', () => {
+ const trackingSpy = mockTracking('_category_', wrapper.vm.$el, jest.spyOn);
+
+ triggerEvent('[data-testid="diff-file-copy-clipboard"]');
+
+ expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
+ label: 'diff_copy_file_path_button',
+ property: 'diff_copy_file',
+ });
+ });
});
describe('for submodule', () => {
@@ -188,6 +208,14 @@ describe('DiffFileHeader component', () => {
});
expect(findFileActions().exists()).toBe(false);
});
+
+ it('renders submodule icon', () => {
+ createComponent({
+ diffFile: submoduleDiffFile,
+ });
+
+ expect(wrapper.find(FileIcon).props('submodule')).toBe(true);
+ });
});
describe('for any file', () => {
diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js
index a6f0d2bf11d..71e0ffd176f 100644
--- a/spec/frontend/diffs/components/diff_file_spec.js
+++ b/spec/frontend/diffs/components/diff_file_spec.js
@@ -1,262 +1,422 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import { mockTracking, triggerEvent } from 'helpers/tracking_helper';
-import { createStore } from '~/mr_notes/stores';
-import DiffFileComponent from '~/diffs/components/diff_file.vue';
-import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
+import Vuex from 'vuex';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+
+import createDiffsStore from '~/diffs/store/modules';
+import createNotesStore from '~/notes/stores/modules';
import diffFileMockDataReadable from '../mock_data/diff_file';
import diffFileMockDataUnreadable from '../mock_data/diff_file_unreadable';
-describe('DiffFile', () => {
- let vm;
- let trackingSpy;
+import DiffFileComponent from '~/diffs/components/diff_file.vue';
+import DiffFileHeaderComponent from '~/diffs/components/diff_file_header.vue';
+import DiffContentComponent from '~/diffs/components/diff_content.vue';
- beforeEach(() => {
- vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
- file: JSON.parse(JSON.stringify(diffFileMockDataReadable)),
+import eventHub from '~/diffs/event_hub';
+import {
+ EVT_EXPAND_ALL_FILES,
+ EVT_PERF_MARK_DIFF_FILES_END,
+ EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN,
+} from '~/diffs/constants';
+
+import { diffViewerModes, diffViewerErrors } from '~/ide/constants';
+
+function changeViewer(store, index, { automaticallyCollapsed, manuallyCollapsed, name }) {
+ const file = store.state.diffs.diffFiles[index];
+ const newViewer = {
+ ...file.viewer,
+ };
+
+ if (automaticallyCollapsed !== undefined) {
+ newViewer.automaticallyCollapsed = automaticallyCollapsed;
+ }
+
+ if (manuallyCollapsed !== undefined) {
+ newViewer.manuallyCollapsed = manuallyCollapsed;
+ }
+
+ if (name !== undefined) {
+ newViewer.name = name;
+ }
+
+ Object.assign(file, {
+ viewer: newViewer,
+ });
+}
+
+function forceHasDiff({ store, index = 0, inlineLines, parallelLines, readableText }) {
+ const file = store.state.diffs.diffFiles[index];
+
+ Object.assign(file, {
+ highlighted_diff_lines: inlineLines,
+ parallel_diff_lines: parallelLines,
+ blob: {
+ ...file.blob,
+ readable_text: readableText,
+ },
+ });
+}
+
+function markFileToBeRendered(store, index = 0) {
+ const file = store.state.diffs.diffFiles[index];
+
+ Object.assign(file, {
+ renderIt: true,
+ });
+}
+
+function createComponent({ file, first = false, last = false }) {
+ const localVue = createLocalVue();
+
+ localVue.use(Vuex);
+
+ const store = new Vuex.Store({
+ ...createNotesStore(),
+ modules: {
+ diffs: createDiffsStore(),
+ },
+ });
+
+ store.state.diffs.diffFiles = [file];
+
+ const wrapper = shallowMount(DiffFileComponent, {
+ store,
+ localVue,
+ propsData: {
+ file,
canCurrentUserFork: false,
viewDiffsFileByFile: false,
- }).$mount();
- trackingSpy = mockTracking('_category_', vm.$el, jest.spyOn);
+ isFirstFile: first,
+ isLastFile: last,
+ },
+ });
+
+ return {
+ localVue,
+ wrapper,
+ store,
+ };
+}
+
+const findDiffHeader = wrapper => wrapper.find(DiffFileHeaderComponent);
+const findDiffContentArea = wrapper => wrapper.find('[data-testid="content-area"]');
+const findLoader = wrapper => wrapper.find('[data-testid="loader-icon"]');
+const findToggleButton = wrapper => wrapper.find('[data-testid="expand-button"]');
+
+const toggleFile = wrapper => findDiffHeader(wrapper).vm.$emit('toggleFile');
+const isDisplayNone = element => element.style.display === 'none';
+const getReadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataReadable));
+const getUnreadableFile = () => JSON.parse(JSON.stringify(diffFileMockDataUnreadable));
+
+const makeFileAutomaticallyCollapsed = (store, index = 0) =>
+ changeViewer(store, index, { automaticallyCollapsed: true, manuallyCollapsed: null });
+const makeFileOpenByDefault = (store, index = 0) =>
+ changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: null });
+const makeFileManuallyCollapsed = (store, index = 0) =>
+ changeViewer(store, index, { automaticallyCollapsed: false, manuallyCollapsed: true });
+const changeViewerType = (store, newType, index = 0) =>
+ changeViewer(store, index, { name: diffViewerModes[newType] });
+
+describe('DiffFile', () => {
+ let wrapper;
+ let store;
+
+ beforeEach(() => {
+ ({ wrapper, store } = createComponent({ file: getReadableFile() }));
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
- const findDiffContent = () => vm.$el.querySelector('.diff-content');
- const isVisible = el => el.style.display !== 'none';
+ describe('bus events', () => {
+ beforeEach(() => {
+ jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ });
+
+ describe('during mount', () => {
+ it.each`
+ first | last | events | file
+ ${false} | ${false} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }}
+ ${true} | ${true} | ${[]} | ${{ inlineLines: [], parallelLines: [], readableText: true }}
+ ${true} | ${false} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN]} | ${false}
+ ${false} | ${true} | ${[EVT_PERF_MARK_DIFF_FILES_END]} | ${false}
+ ${true} | ${true} | ${[EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN, EVT_PERF_MARK_DIFF_FILES_END]} | ${false}
+ `(
+ 'emits the events $events based on the file and its position ({ first: $first, last: $last }) among all files',
+ async ({ file, first, last, events }) => {
+ if (file) {
+ forceHasDiff({ store, ...file });
+ }
+
+ ({ wrapper, store } = createComponent({
+ file: store.state.diffs.diffFiles[0],
+ first,
+ last,
+ }));
+
+ await wrapper.vm.$nextTick();
+
+ expect(eventHub.$emit).toHaveBeenCalledTimes(events.length);
+ events.forEach(event => {
+ expect(eventHub.$emit).toHaveBeenCalledWith(event);
+ });
+ },
+ );
+ });
+
+ describe('after loading the diff', () => {
+ it('indicates that it loaded the file', async () => {
+ forceHasDiff({ store, inlineLines: [], parallelLines: [], readableText: true });
+ ({ wrapper, store } = createComponent({
+ file: store.state.diffs.diffFiles[0],
+ first: true,
+ last: true,
+ }));
+
+ jest.spyOn(wrapper.vm, 'loadCollapsedDiff').mockResolvedValue(getReadableFile());
+ jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn());
+
+ makeFileAutomaticallyCollapsed(store);
+
+ await wrapper.vm.$nextTick(); // Wait for store updates to flow into the component
+
+ toggleFile(wrapper);
+
+ await wrapper.vm.$nextTick(); // Wait for the load to resolve
+ await wrapper.vm.$nextTick(); // Wait for the idleCallback
+ await wrapper.vm.$nextTick(); // Wait for nextTick inside postRender
+
+ expect(eventHub.$emit).toHaveBeenCalledTimes(2);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_FIRST_DIFF_FILE_SHOWN);
+ expect(eventHub.$emit).toHaveBeenCalledWith(EVT_PERF_MARK_DIFF_FILES_END);
+ });
+ });
+ });
describe('template', () => {
- it('should render component with file header, file content components', done => {
- const el = vm.$el;
- const { file_hash, file_path } = vm.file;
+ it('should render component with file header, file content components', async () => {
+ const el = wrapper.vm.$el;
+ const { file_hash } = wrapper.vm.file;
expect(el.id).toEqual(file_hash);
expect(el.classList.contains('diff-file')).toEqual(true);
expect(el.querySelectorAll('.diff-content.hidden').length).toEqual(0);
expect(el.querySelector('.js-file-title')).toBeDefined();
- expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
- expect(el.querySelector('.file-title-name').innerText.indexOf(file_path)).toBeGreaterThan(-1);
+ expect(wrapper.find(DiffFileHeaderComponent).exists()).toBe(true);
expect(el.querySelector('.js-syntax-highlight')).toBeDefined();
- vm.file.renderIt = true;
+ markFileToBeRendered(store);
+
+ await wrapper.vm.$nextTick();
- vm.$nextTick()
- .then(() => {
- expect(el.querySelectorAll('.line_content').length).toBe(8);
- expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1);
- triggerEvent('[data-testid="diff-file-copy-clipboard"]');
- })
- .then(done)
- .catch(done.fail);
+ expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
});
+ });
- it('should track a click event on copy to clip board button', done => {
- const el = vm.$el;
+ describe('collapsing', () => {
+ describe(`\`${EVT_EXPAND_ALL_FILES}\` event`, () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'handleToggle').mockImplementation(() => {});
+ });
- expect(el.querySelector('[data-testid="diff-file-copy-clipboard"]')).toBeDefined();
- vm.file.renderIt = true;
- vm.$nextTick()
- .then(() => {
- triggerEvent('[data-testid="diff-file-copy-clipboard"]');
+ it('performs the normal file toggle when the file is collapsed', async () => {
+ makeFileAutomaticallyCollapsed(store);
- expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_copy_file_button', {
- label: 'diff_copy_file_path_button',
- property: 'diff_copy_file',
- });
- })
- .then(done)
- .catch(done.fail);
- });
+ await wrapper.vm.$nextTick();
- describe('collapsed', () => {
- it('should not have file content', done => {
- expect(isVisible(findDiffContent())).toBe(true);
- expect(vm.isCollapsed).toEqual(false);
- vm.isCollapsed = true;
- vm.file.renderIt = true;
+ eventHub.$emit(EVT_EXPAND_ALL_FILES);
- vm.$nextTick(() => {
- expect(isVisible(findDiffContent())).toBe(false);
-
- done();
- });
+ expect(wrapper.vm.handleToggle).toHaveBeenCalledTimes(1);
});
- it('should have collapsed text and link', done => {
- vm.renderIt = true;
- vm.isCollapsed = true;
+ it('does nothing when the file is not collapsed', async () => {
+ eventHub.$emit(EVT_EXPAND_ALL_FILES);
- vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
+ await wrapper.vm.$nextTick();
- done();
- });
+ expect(wrapper.vm.handleToggle).not.toHaveBeenCalled();
});
+ });
- it('should have collapsed text and link even before rendered', done => {
- vm.renderIt = false;
- vm.isCollapsed = true;
+ describe('user collapsed', () => {
+ beforeEach(() => {
+ makeFileManuallyCollapsed(store);
+ });
- vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
+ it('should not have any content at all', async () => {
+ await wrapper.vm.$nextTick();
- done();
+ Array.from(findDiffContentArea(wrapper).element.children).forEach(child => {
+ expect(isDisplayNone(child)).toBe(true);
});
});
- it('should be collapsable for unreadable files', done => {
- vm.$destroy();
- vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
- file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)),
- canCurrentUserFork: false,
- viewDiffsFileByFile: false,
- }).$mount();
+ it('should not have the class `has-body` to present the header differently', () => {
+ expect(wrapper.classes('has-body')).toBe(false);
+ });
+ });
- vm.renderIt = false;
- vm.isCollapsed = true;
+ describe('automatically collapsed', () => {
+ beforeEach(() => {
+ makeFileAutomaticallyCollapsed(store);
+ });
- vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain('This diff is collapsed');
- expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1);
+ it('should show the collapsed file warning with expansion button', () => {
+ expect(findDiffContentArea(wrapper).html()).toContain(
+ 'Files with large changes are collapsed by default.',
+ );
+ expect(findToggleButton(wrapper).exists()).toBe(true);
+ });
- done();
- });
+ it('should style the component so that it `.has-body` for layout purposes', () => {
+ expect(wrapper.classes('has-body')).toBe(true);
});
+ });
- it('should be collapsed for renamed files', done => {
- vm.renderIt = true;
- vm.isCollapsed = false;
- vm.file.highlighted_diff_lines = null;
- vm.file.viewer.name = diffViewerModes.renamed;
+ describe('not collapsed', () => {
+ beforeEach(() => {
+ makeFileOpenByDefault(store);
+ markFileToBeRendered(store);
+ });
- vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This diff is collapsed');
+ it('should have the file content', async () => {
+ expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
+ });
- done();
- });
+ it('should style the component so that it `.has-body` for layout purposes', () => {
+ expect(wrapper.classes('has-body')).toBe(true);
});
+ });
- it('should be collapsed for mode changed files', done => {
- vm.renderIt = true;
- vm.isCollapsed = false;
- vm.file.highlighted_diff_lines = null;
- vm.file.viewer.name = diffViewerModes.mode_changed;
+ describe('toggle', () => {
+ it('should update store state', async () => {
+ jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(() => {});
- vm.$nextTick(() => {
- expect(vm.$el.innerText).not.toContain('This diff is collapsed');
+ toggleFile(wrapper);
- done();
+ expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsedByUser', {
+ filePath: wrapper.vm.file.file_path,
+ collapsed: true,
});
});
- it('should have loading icon while loading a collapsed diffs', done => {
- vm.isCollapsed = true;
- vm.isLoadingCollapsedDiff = true;
+ describe('fetch collapsed diff', () => {
+ const prepFile = async (inlineLines, parallelLines, readableText) => {
+ forceHasDiff({
+ store,
+ inlineLines,
+ parallelLines,
+ readableText,
+ });
+
+ await wrapper.vm.$nextTick();
- vm.$nextTick(() => {
- expect(vm.$el.querySelectorAll('.diff-content.loading').length).toEqual(1);
+ toggleFile(wrapper);
+ };
- done();
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'requestDiff').mockImplementation(() => {});
+
+ makeFileAutomaticallyCollapsed(store);
});
- });
- it('should update store state', done => {
- jest.spyOn(vm.$store, 'dispatch').mockImplementation(() => {});
+ it.each`
+ inlineLines | parallelLines | readableText
+ ${[1]} | ${[1]} | ${true}
+ ${[]} | ${[1]} | ${true}
+ ${[1]} | ${[]} | ${true}
+ ${[1]} | ${[1]} | ${false}
+ ${[]} | ${[]} | ${false}
+ `(
+ 'does not make a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }',
+ async ({ inlineLines, parallelLines, readableText }) => {
+ await prepFile(inlineLines, parallelLines, readableText);
+
+ expect(wrapper.vm.requestDiff).not.toHaveBeenCalled();
+ },
+ );
- vm.isCollapsed = true;
+ it.each`
+ inlineLines | parallelLines | readableText
+ ${[]} | ${[]} | ${true}
+ `(
+ 'makes a request to fetch the diff for a diff file like { inline: $inlineLines, parallel: $parallelLines, readableText: $readableText }',
+ async ({ inlineLines, parallelLines, readableText }) => {
+ await prepFile(inlineLines, parallelLines, readableText);
- vm.$nextTick(() => {
- expect(vm.$store.dispatch).toHaveBeenCalledWith('diffs/setFileCollapsed', {
- filePath: vm.file.file_path,
- collapsed: true,
- });
+ expect(wrapper.vm.requestDiff).toHaveBeenCalled();
+ },
+ );
+ });
+ });
- done();
- });
+ describe('loading', () => {
+ it('should have loading icon while loading a collapsed diffs', async () => {
+ makeFileAutomaticallyCollapsed(store);
+ wrapper.vm.isLoadingCollapsedDiff = true;
+
+ await wrapper.vm.$nextTick();
+
+ expect(findLoader(wrapper).exists()).toBe(true);
});
+ });
- it('updates local state when changing file state', done => {
- vm.file.viewer.automaticallyCollapsed = true;
+ describe('general (other) collapsed', () => {
+ it('should be expandable for unreadable files', async () => {
+ ({ wrapper, store } = createComponent({ file: getUnreadableFile() }));
+ makeFileAutomaticallyCollapsed(store);
- vm.$nextTick(() => {
- expect(vm.isCollapsed).toBe(true);
+ await wrapper.vm.$nextTick();
- done();
- });
+ expect(findDiffContentArea(wrapper).html()).toContain(
+ 'Files with large changes are collapsed by default.',
+ );
+ expect(findToggleButton(wrapper).exists()).toBe(true);
});
+
+ it.each`
+ mode
+ ${'renamed'}
+ ${'mode_changed'}
+ `(
+ 'should render the DiffContent component for files whose mode is $mode',
+ async ({ mode }) => {
+ makeFileOpenByDefault(store);
+ markFileToBeRendered(store);
+ changeViewerType(store, mode);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.classes('has-body')).toBe(true);
+ expect(wrapper.find(DiffContentComponent).exists()).toBe(true);
+ expect(wrapper.find(DiffContentComponent).isVisible()).toBe(true);
+ },
+ );
});
});
describe('too large diff', () => {
- it('should have too large warning and blob link', done => {
+ it('should have too large warning and blob link', async () => {
+ const file = store.state.diffs.diffFiles[0];
const BLOB_LINK = '/file/view/path';
- vm.file.viewer.error = diffViewerErrors.too_large;
- vm.file.viewer.error_message =
- 'This source diff could not be displayed because it is too large';
- vm.file.view_path = BLOB_LINK;
- vm.file.renderIt = true;
-
- vm.$nextTick(() => {
- expect(vm.$el.innerText).toContain(
- 'This source diff could not be displayed because it is too large',
- );
- done();
+ Object.assign(store.state.diffs.diffFiles[0], {
+ ...file,
+ view_path: BLOB_LINK,
+ renderIt: true,
+ viewer: {
+ ...file.viewer,
+ error: diffViewerErrors.too_large,
+ error_message: 'This source diff could not be displayed because it is too large',
+ },
});
- });
- });
- describe('watch collapsed', () => {
- it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => {
- jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
-
- vm.file.highlighted_diff_lines = [];
- vm.file.parallel_diff_lines = [];
- vm.isCollapsed = true;
-
- vm.$nextTick()
- .then(() => {
- vm.isCollapsed = false;
-
- return vm.$nextTick();
- })
- .then(() => {
- expect(vm.handleLoadCollapsedDiff).toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
- });
+ await wrapper.vm.$nextTick();
- it('does not call handleLoadCollapsedDiff if collapsed changed & file is unreadable', done => {
- vm.$destroy();
- vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), {
- file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)),
- canCurrentUserFork: false,
- viewDiffsFileByFile: false,
- }).$mount();
-
- jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {});
-
- vm.file.highlighted_diff_lines = [];
- vm.file.parallel_diff_lines = undefined;
- vm.isCollapsed = true;
-
- vm.$nextTick()
- .then(() => {
- vm.isCollapsed = false;
-
- return vm.$nextTick();
- })
- .then(() => {
- expect(vm.handleLoadCollapsedDiff).not.toHaveBeenCalled();
- })
- .then(done)
- .catch(done.fail);
+ expect(wrapper.vm.$el.innerText).toContain(
+ 'This source diff could not be displayed because it is too large',
+ );
});
});
});
diff --git a/spec/frontend/diffs/components/diff_row_spec.js b/spec/frontend/diffs/components/diff_row_spec.js
new file mode 100644
index 00000000000..f9e76cf8107
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_row_spec.js
@@ -0,0 +1,127 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import diffsModule from '~/diffs/store/modules';
+import DiffRow from '~/diffs/components/diff_row.vue';
+
+describe('DiffRow', () => {
+ const testLines = [
+ {
+ left: { old_line: 1, discussions: [] },
+ right: { new_line: 1, discussions: [] },
+ hasDiscussionsLeft: true,
+ hasDiscussionsRight: true,
+ },
+ {
+ left: {},
+ right: {},
+ isMatchLineLeft: true,
+ isMatchLineRight: true,
+ },
+ {},
+ {
+ left: { old_line: 1, discussions: [] },
+ right: { new_line: 1, discussions: [] },
+ },
+ ];
+
+ const createWrapper = ({ props, state, isLoggedIn = true }) => {
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const diffs = diffsModule();
+ diffs.state = { ...diffs.state, ...state };
+
+ const getters = { isLoggedIn: () => isLoggedIn };
+
+ const store = new Vuex.Store({
+ modules: { diffs },
+ getters,
+ });
+
+ const propsData = {
+ fileHash: 'abc',
+ filePath: 'abc',
+ line: {},
+ ...props,
+ };
+ return shallowMount(DiffRow, { propsData, localVue, store });
+ };
+
+ it('isHighlighted returns true if isCommented is true', () => {
+ const props = { isCommented: true };
+ const wrapper = createWrapper({ props });
+ expect(wrapper.vm.isHighlighted).toBe(true);
+ });
+
+ it('isHighlighted returns true given line.left', () => {
+ const props = {
+ line: {
+ left: {
+ line_code: 'abc',
+ },
+ },
+ };
+ const state = { highlightedRow: 'abc' };
+ const wrapper = createWrapper({ props, state });
+ expect(wrapper.vm.isHighlighted).toBe(true);
+ });
+
+ it('isHighlighted returns true given line.right', () => {
+ const props = {
+ line: {
+ right: {
+ line_code: 'abc',
+ },
+ },
+ };
+ const state = { highlightedRow: 'abc' };
+ const wrapper = createWrapper({ props, state });
+ expect(wrapper.vm.isHighlighted).toBe(true);
+ });
+
+ it('isHighlighted returns false given line.left', () => {
+ const props = {
+ line: {
+ left: {
+ line_code: 'abc',
+ },
+ },
+ };
+ const wrapper = createWrapper({ props });
+ expect(wrapper.vm.isHighlighted).toBe(false);
+ });
+
+ describe.each`
+ side
+ ${'left'}
+ ${'right'}
+ `('$side side', ({ side }) => {
+ it(`renders empty cells if ${side} is unavailable`, () => {
+ const wrapper = createWrapper({ props: { line: testLines[2] } });
+ expect(wrapper.find(`[data-testid="${side}LineNumber"]`).exists()).toBe(false);
+ expect(wrapper.find(`[data-testid="${side}EmptyCell"]`).exists()).toBe(true);
+ });
+
+ it('renders comment button', () => {
+ const wrapper = createWrapper({ props: { line: testLines[3] } });
+ expect(wrapper.find(`[data-testid="${side}CommentButton"]`).exists()).toBe(true);
+ });
+
+ it('renders avatars', () => {
+ const wrapper = createWrapper({ props: { line: testLines[0] } });
+ expect(wrapper.find(`[data-testid="${side}Discussions"]`).exists()).toBe(true);
+ });
+ });
+
+ it('renders left line numbers', () => {
+ const wrapper = createWrapper({ props: { line: testLines[0] } });
+ const lineNumber = testLines[0].left.old_line;
+ expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true);
+ });
+
+ it('renders right line numbers', () => {
+ const wrapper = createWrapper({ props: { line: testLines[0] } });
+ const lineNumber = testLines[0].right.new_line;
+ expect(wrapper.find(`[data-linenumber="${lineNumber}"]`).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_row_utils_spec.js b/spec/frontend/diffs/components/diff_row_utils_spec.js
index 394b6cb1914..c001857fa49 100644
--- a/spec/frontend/diffs/components/diff_row_utils_spec.js
+++ b/spec/frontend/diffs/components/diff_row_utils_spec.js
@@ -201,3 +201,76 @@ describe('shouldShowCommentButton', () => {
},
);
});
+
+describe('mapParallel', () => {
+ it('should assign computed properties to the line object', () => {
+ const side = {
+ discussions: [{}],
+ discussionsExpanded: true,
+ hasForm: true,
+ };
+ const content = {
+ diffFile: {},
+ hasParallelDraftLeft: () => false,
+ hasParallelDraftRight: () => false,
+ draftForLine: () => ({}),
+ };
+ const line = { left: side, right: side };
+ const expectation = {
+ commentRowClasses: '',
+ draftRowClasses: 'js-temp-notes-holder',
+ hasDiscussionsLeft: true,
+ hasDiscussionsRight: true,
+ isContextLineLeft: false,
+ isContextLineRight: false,
+ isMatchLineLeft: false,
+ isMatchLineRight: false,
+ isMetaLineLeft: false,
+ isMetaLineRight: false,
+ };
+ const leftExpectation = {
+ renderDiscussion: true,
+ hasDraft: false,
+ lineDraft: {},
+ hasCommentForm: true,
+ };
+ const rightExpectation = {
+ renderDiscussion: false,
+ hasDraft: false,
+ lineDraft: {},
+ hasCommentForm: false,
+ };
+ const mapped = utils.mapParallel(content)(line);
+
+ expect(mapped).toMatchObject(expectation);
+ expect(mapped.left).toMatchObject(leftExpectation);
+ expect(mapped.right).toMatchObject(rightExpectation);
+ });
+});
+
+describe('mapInline', () => {
+ it('should assign computed properties to the line object', () => {
+ const content = {
+ diffFile: {},
+ shouldRenderDraftRow: () => false,
+ };
+ const line = {
+ discussions: [{}],
+ discussionsExpanded: true,
+ hasForm: true,
+ };
+ const expectation = {
+ commentRowClasses: '',
+ hasDiscussions: true,
+ isContextLine: false,
+ isMatchLine: false,
+ isMetaLine: false,
+ renderDiscussion: true,
+ hasDraft: false,
+ hasCommentForm: true,
+ };
+ const mapped = utils.mapInline(content)(line);
+
+ expect(mapped).toMatchObject(expectation);
+ });
+});
diff --git a/spec/frontend/diffs/components/diff_view_spec.js b/spec/frontend/diffs/components/diff_view_spec.js
new file mode 100644
index 00000000000..4d90112d8f6
--- /dev/null
+++ b/spec/frontend/diffs/components/diff_view_spec.js
@@ -0,0 +1,82 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import DiffView from '~/diffs/components/diff_view.vue';
+// import DraftNote from '~/batch_comments/components/draft_note.vue';
+// import DiffRow from '~/diffs/components/diff_row.vue';
+// import DiffCommentCell from '~/diffs/components/diff_comment_cell.vue';
+// import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue';
+
+describe('DiffView', () => {
+ const DiffExpansionCell = { template: `<div/>` };
+ const DiffRow = { template: `<div/>` };
+ const DiffCommentCell = { template: `<div/>` };
+ const DraftNote = { template: `<div/>` };
+ const createWrapper = props => {
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+
+ const batchComments = {
+ getters: {
+ shouldRenderDraftRow: () => false,
+ shouldRenderParallelDraftRow: () => () => true,
+ draftForLine: () => false,
+ draftsForFile: () => false,
+ hasParallelDraftLeft: () => false,
+ hasParallelDraftRight: () => false,
+ },
+ namespaced: true,
+ };
+ const diffs = { getters: { commitId: () => 'abc123' }, namespaced: true };
+ const notes = {
+ state: { selectedCommentPosition: null, selectedCommentPositionHover: null },
+ };
+
+ const store = new Vuex.Store({
+ modules: { diffs, notes, batchComments },
+ });
+
+ const propsData = {
+ diffFile: {},
+ diffLines: [],
+ ...props,
+ };
+ const stubs = { DiffExpansionCell, DiffRow, DiffCommentCell, DraftNote };
+ return shallowMount(DiffView, { propsData, store, localVue, stubs });
+ };
+
+ it('renders a match line', () => {
+ const wrapper = createWrapper({ diffLines: [{ isMatchLineLeft: true }] });
+ expect(wrapper.find(DiffExpansionCell).exists()).toBe(true);
+ });
+
+ it.each`
+ type | side | container | sides | total
+ ${'parallel'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${2}
+ ${'parallel'} | ${'right'} | ${'.new'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${2}
+ ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} } }} | ${1}
+ ${'inline'} | ${'right'} | ${'.new'} | ${{ right: { lineDraft: {} } }} | ${1}
+ ${'inline'} | ${'left'} | ${'.old'} | ${{ left: { lineDraft: {} }, right: { lineDraft: {} } }} | ${1}
+ `(
+ 'renders a $type comment row with comment cell on $side',
+ ({ type, container, sides, total }) => {
+ const wrapper = createWrapper({
+ diffLines: [{ renderCommentRow: true, ...sides }],
+ inline: type === 'inline',
+ });
+ expect(wrapper.findAll(DiffCommentCell).length).toBe(total);
+ expect(
+ wrapper
+ .find(container)
+ .find(DiffCommentCell)
+ .exists(),
+ ).toBe(true);
+ },
+ );
+
+ it('renders a draft row', () => {
+ const wrapper = createWrapper({
+ diffLines: [{ renderCommentRow: true, left: { lineDraft: { isDraft: true } } }],
+ });
+ expect(wrapper.find(DraftNote).exists()).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
deleted file mode 100644
index 81e5403d502..00000000000
--- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js
+++ /dev/null
@@ -1,32 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import { createStore } from '~/mr_notes/stores';
-import InlineDiffExpansionRow from '~/diffs/components/inline_diff_expansion_row.vue';
-import diffFileMockData from '../mock_data/diff_file';
-
-describe('InlineDiffExpansionRow', () => {
- const mockData = { ...diffFileMockData };
- const matchLine = mockData.highlighted_diff_lines.pop();
-
- const createComponent = (options = {}) => {
- const cmp = Vue.extend(InlineDiffExpansionRow);
- const defaults = {
- fileHash: mockData.file_hash,
- contextLinesPath: 'contextLinesPath',
- line: matchLine,
- isTop: false,
- isBottom: false,
- };
- const props = { ...defaults, ...options };
-
- return createComponentWithStore(cmp, createStore(), props).$mount();
- };
-
- describe('template', () => {
- it('should render expansion row for match lines', () => {
- const vm = createComponent();
-
- expect(vm.$el.classList.contains('line_expansion')).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
index c65a39b9083..21e7d7397a0 100644
--- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js
@@ -4,6 +4,7 @@ import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue';
import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import diffFileMockData from '../mock_data/diff_file';
import discussionsMockData from '../mock_data/diff_discussions';
+import { mapInline } from '~/diffs/components/diff_row_utils';
const TEST_USER_ID = 'abc123';
const TEST_USER = { id: TEST_USER_ID };
@@ -11,7 +12,16 @@ const TEST_USER = { id: TEST_USER_ID };
describe('InlineDiffTableRow', () => {
let wrapper;
let store;
- const thisLine = diffFileMockData.highlighted_diff_lines[0];
+ const mockDiffContent = {
+ diffFile: diffFileMockData,
+ shouldRenderDraftRow: jest.fn(),
+ hasParallelDraftLeft: jest.fn(),
+ hasParallelDraftRight: jest.fn(),
+ draftForLine: jest.fn(),
+ };
+
+ const applyMap = mapInline(mockDiffContent);
+ const thisLine = applyMap(diffFileMockData.highlighted_diff_lines[0]);
const createComponent = (props = {}, propsStore = store) => {
wrapper = shallowMount(InlineDiffTableRow, {
@@ -132,7 +142,7 @@ describe('InlineDiffTableRow', () => {
${true} | ${{ ...thisLine, type: 'old-nonewline', discussions: [] }} | ${false}
${true} | ${{ ...thisLine, discussions: [{}] }} | ${false}
`('visible is $expectation - line ($line)', ({ isHover, line, expectation }) => {
- createComponent({ line });
+ createComponent({ line: applyMap(line) });
wrapper.setData({ isHover });
return wrapper.vm.$nextTick().then(() => {
@@ -148,7 +158,7 @@ describe('InlineDiffTableRow', () => {
'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled',
({ disabled, commentsDisabled }) => {
createComponent({
- line: { ...thisLine, commentsDisabled },
+ line: applyMap({ ...thisLine, commentsDisabled }),
});
wrapper.setData({ isHover: true });
@@ -177,7 +187,7 @@ describe('InlineDiffTableRow', () => {
'has the correct tooltip when commentsDisabled=$commentsDisabled',
({ tooltip, commentsDisabled }) => {
createComponent({
- line: { ...thisLine, commentsDisabled },
+ line: applyMap({ ...thisLine, commentsDisabled }),
});
wrapper.setData({ isHover: true });
@@ -216,7 +226,7 @@ describe('InlineDiffTableRow', () => {
beforeEach(() => {
jest.spyOn(store, 'dispatch').mockImplementation();
createComponent({
- line: { ...thisLine, ...lineProps },
+ line: applyMap({ ...thisLine, ...lineProps }),
});
});
@@ -268,7 +278,7 @@ describe('InlineDiffTableRow', () => {
describe('with showCommentButton', () => {
it('renders if line has discussions', () => {
- createComponent({ line });
+ createComponent({ line: applyMap(line) });
expect(findAvatars().props()).toEqual({
discussions: line.discussions,
@@ -278,13 +288,13 @@ describe('InlineDiffTableRow', () => {
it('does notrender if line has no discussions', () => {
line.discussions = [];
- createComponent({ line });
+ createComponent({ line: applyMap(line) });
expect(findAvatars().exists()).toEqual(false);
});
it('toggles line discussion', () => {
- createComponent({ line });
+ createComponent({ line: applyMap(line) });
expect(store.dispatch).toHaveBeenCalledTimes(1);
diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js
index 39c581e2796..6a1791509fd 100644
--- a/spec/frontend/diffs/components/inline_diff_view_spec.js
+++ b/spec/frontend/diffs/components/inline_diff_view_spec.js
@@ -1,54 +1,57 @@
-import Vue from 'vue';
import '~/behaviors/markdown/render_gfm';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import { mount } from '@vue/test-utils';
+import { getByText } from '@testing-library/dom';
import { createStore } from '~/mr_notes/stores';
import InlineDiffView from '~/diffs/components/inline_diff_view.vue';
+import { mapInline } from '~/diffs/components/diff_row_utils';
import diffFileMockData from '../mock_data/diff_file';
import discussionsMockData from '../mock_data/diff_discussions';
describe('InlineDiffView', () => {
- let component;
+ let wrapper;
const getDiffFileMock = () => ({ ...diffFileMockData });
const getDiscussionsMockData = () => [{ ...discussionsMockData }];
const notesLength = getDiscussionsMockData()[0].notes.length;
- beforeEach(done => {
- const diffFile = getDiffFileMock();
+ const setup = (diffFile, diffLines) => {
+ const mockDiffContent = {
+ diffFile,
+ shouldRenderDraftRow: jest.fn(),
+ };
const store = createStore();
store.dispatch('diffs/setInlineDiffViewType');
- component = createComponentWithStore(Vue.extend(InlineDiffView), store, {
- diffFile,
- diffLines: diffFile.highlighted_diff_lines,
- }).$mount();
-
- Vue.nextTick(done);
- });
+ wrapper = mount(InlineDiffView, {
+ store,
+ propsData: {
+ diffFile,
+ diffLines: diffLines.map(mapInline(mockDiffContent)),
+ },
+ });
+ };
describe('template', () => {
it('should have rendered diff lines', () => {
- const el = component.$el;
+ const diffFile = getDiffFileMock();
+ setup(diffFile, diffFile.highlighted_diff_lines);
- expect(el.querySelectorAll('tr.line_holder').length).toEqual(8);
- expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(4);
- expect(el.querySelectorAll('tr.line_expansion.match').length).toEqual(1);
- expect(el.textContent.indexOf('Bad dates')).toBeGreaterThan(-1);
+ expect(wrapper.findAll('tr.line_holder').length).toEqual(8);
+ expect(wrapper.findAll('tr.line_holder.new').length).toEqual(4);
+ expect(wrapper.findAll('tr.line_expansion.match').length).toEqual(1);
+ getByText(wrapper.element, /Bad dates/i);
});
- it('should render discussions', done => {
- const el = component.$el;
- component.diffLines[1].discussions = getDiscussionsMockData();
- component.diffLines[1].discussionsExpanded = true;
-
- Vue.nextTick(() => {
- expect(el.querySelectorAll('.notes_holder').length).toEqual(1);
- expect(el.querySelectorAll('.notes_holder .note').length).toEqual(notesLength + 1);
- expect(el.innerText.indexOf('comment 5')).toBeGreaterThan(-1);
- component.$store.dispatch('setInitialNotes', []);
+ it('should render discussions', () => {
+ const diffFile = getDiffFileMock();
+ diffFile.highlighted_diff_lines[1].discussions = getDiscussionsMockData();
+ diffFile.highlighted_diff_lines[1].discussionsExpanded = true;
+ setup(diffFile, diffFile.highlighted_diff_lines);
- done();
- });
+ expect(wrapper.findAll('.notes_holder').length).toEqual(1);
+ expect(wrapper.findAll('.notes_holder .note').length).toEqual(notesLength + 1);
+ getByText(wrapper.element, 'comment 5');
+ wrapper.vm.$store.dispatch('setInitialNotes', []);
});
});
});
diff --git a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js b/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
deleted file mode 100644
index 38112445e8d..00000000000
--- a/spec/frontend/diffs/components/parallel_diff_expansion_row_spec.js
+++ /dev/null
@@ -1,31 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import { createStore } from '~/mr_notes/stores';
-import ParallelDiffExpansionRow from '~/diffs/components/parallel_diff_expansion_row.vue';
-import diffFileMockData from '../mock_data/diff_file';
-
-describe('ParallelDiffExpansionRow', () => {
- const matchLine = diffFileMockData.highlighted_diff_lines[5];
-
- const createComponent = (options = {}) => {
- const cmp = Vue.extend(ParallelDiffExpansionRow);
- const defaults = {
- fileHash: diffFileMockData.file_hash,
- contextLinesPath: 'contextLinesPath',
- line: matchLine,
- isTop: false,
- isBottom: false,
- };
- const props = { ...defaults, ...options };
-
- return createComponentWithStore(cmp, createStore(), props).$mount();
- };
-
- describe('template', () => {
- it('should render expansion row for match lines', () => {
- const vm = createComponent();
-
- expect(vm.$el.classList.contains('line_expansion')).toBe(true);
- });
- });
-});
diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
index 13031bd8b66..57eff177261 100644
--- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
+++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js
@@ -3,11 +3,22 @@ import { shallowMount } from '@vue/test-utils';
import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
import { createStore } from '~/mr_notes/stores';
import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue';
+import { mapParallel } from '~/diffs/components/diff_row_utils';
import diffFileMockData from '../mock_data/diff_file';
import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue';
import discussionsMockData from '../mock_data/diff_discussions';
describe('ParallelDiffTableRow', () => {
+ const mockDiffContent = {
+ diffFile: diffFileMockData,
+ shouldRenderDraftRow: jest.fn(),
+ hasParallelDraftLeft: jest.fn(),
+ hasParallelDraftRight: jest.fn(),
+ draftForLine: jest.fn(),
+ };
+
+ const applyMap = mapParallel(mockDiffContent);
+
describe('when one side is empty', () => {
let wrapper;
let vm;
@@ -18,7 +29,7 @@ describe('ParallelDiffTableRow', () => {
wrapper = shallowMount(ParallelDiffTableRow, {
store: createStore(),
propsData: {
- line: thisLine,
+ line: applyMap(thisLine),
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
@@ -67,7 +78,7 @@ describe('ParallelDiffTableRow', () => {
beforeEach(() => {
vm = createComponentWithStore(Vue.extend(ParallelDiffTableRow), createStore(), {
- line: thisLine,
+ line: applyMap(thisLine),
fileHash: diffFileMockData.file_hash,
filePath: diffFileMockData.file_path,
contextLinesPath: 'contextLinesPath',
@@ -243,7 +254,10 @@ describe('ParallelDiffTableRow', () => {
${{ ...thisLine, left: { type: 'old-nonewline', discussions: [] } }} | ${false}
${{ ...thisLine, left: { discussions: [{}] } }} | ${false}
`('visible is $expectation - line ($line)', async ({ line, expectation }) => {
- createComponent({ line }, store, { isLeftHover: true, isCommentButtonRendered: true });
+ createComponent({ line: applyMap(line) }, store, {
+ isLeftHover: true,
+ isCommentButtonRendered: true,
+ });
expect(findNoteButton().isVisible()).toBe(expectation);
});
@@ -320,7 +334,7 @@ describe('ParallelDiffTableRow', () => {
Object.assign(thisLine.left, lineProps);
Object.assign(thisLine.right, lineProps);
createComponent({
- line: { ...thisLine },
+ line: applyMap({ ...thisLine }),
});
});
@@ -357,7 +371,7 @@ describe('ParallelDiffTableRow', () => {
beforeEach(() => {
jest.spyOn(store, 'dispatch').mockImplementation();
- line = {
+ line = applyMap({
left: {
line_code: TEST_LINE_CODE,
type: 'new',
@@ -369,7 +383,7 @@ describe('ParallelDiffTableRow', () => {
rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n',
meta_data: null,
},
- };
+ });
});
describe('with showCommentButton', () => {
@@ -384,7 +398,7 @@ describe('ParallelDiffTableRow', () => {
it('does notrender if line has no discussions', () => {
line.left.discussions = [];
- createComponent({ line });
+ createComponent({ line: applyMap(line) });
expect(findAvatars().exists()).toEqual(false);
});
diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js
index cc177a81d88..c89403e4869 100644
--- a/spec/frontend/diffs/components/tree_list_spec.js
+++ b/spec/frontend/diffs/components/tree_list_spec.js
@@ -91,12 +91,12 @@ describe('Diffs tree list component', () => {
expect(
getFileRows()
.at(0)
- .text(),
+ .html(),
).toContain('index.js');
expect(
getFileRows()
.at(1)
- .text(),
+ .html(),
).toContain('app');
});
@@ -138,7 +138,7 @@ describe('Diffs tree list component', () => {
wrapper.vm.$store.state.diffs.renderTreeList = false;
return wrapper.vm.$nextTick().then(() => {
- expect(wrapper.find('.file-row').text()).toContain('index.js');
+ expect(wrapper.find('.file-row').html()).toContain('index.js');
});
});
});
diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js
index d3886819a91..cef776c885a 100644
--- a/spec/frontend/diffs/mock_data/diff_file.js
+++ b/spec/frontend/diffs/mock_data/diff_file.js
@@ -27,6 +27,7 @@ export default {
name: 'text',
error: null,
automaticallyCollapsed: false,
+ manuallyCollapsed: null,
},
added_lines: 2,
removed_lines: 0,
diff --git a/spec/frontend/diffs/mock_data/diff_file_unreadable.js b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
index f6cdca9950a..2a5d694e3b8 100644
--- a/spec/frontend/diffs/mock_data/diff_file_unreadable.js
+++ b/spec/frontend/diffs/mock_data/diff_file_unreadable.js
@@ -26,6 +26,7 @@ export default {
name: 'text',
error: null,
automaticallyCollapsed: false,
+ manuallyCollapsed: null,
},
added_lines: 0,
removed_lines: 0,
diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js
index c3e4ee9c531..0af5ddd9764 100644
--- a/spec/frontend/diffs/store/actions_spec.js
+++ b/spec/frontend/diffs/store/actions_spec.js
@@ -27,7 +27,6 @@ import {
scrollToLineIfNeededInline,
scrollToLineIfNeededParallel,
loadCollapsedDiff,
- expandAllFiles,
toggleFileDiscussions,
saveDiffDiscussion,
setHighlightedRow,
@@ -42,7 +41,7 @@ import {
fetchFullDiff,
toggleFullDiff,
switchToFullDiffFromRenamedFile,
- setFileCollapsed,
+ setFileCollapsedByUser,
setExpandedDiffLines,
setSuggestPopoverDismissed,
changeCurrentCommit,
@@ -658,23 +657,6 @@ describe('DiffsStoreActions', () => {
});
});
- describe('expandAllFiles', () => {
- it('should change the collapsed prop from the diffFiles', done => {
- testAction(
- expandAllFiles,
- null,
- {},
- [
- {
- type: types.EXPAND_ALL_FILES,
- },
- ],
- [],
- done,
- );
- });
- });
-
describe('toggleFileDiscussions', () => {
it('should dispatch collapseDiscussion when all discussions are expanded', () => {
const getters = {
@@ -1167,7 +1149,11 @@ describe('DiffsStoreActions', () => {
file_hash: 'testhash',
alternate_viewer: { name: updatedViewerName },
};
- const updatedViewer = { name: updatedViewerName, automaticallyCollapsed: false };
+ const updatedViewer = {
+ name: updatedViewerName,
+ automaticallyCollapsed: false,
+ manuallyCollapsed: false,
+ };
const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }];
let renamedFile;
let mock;
@@ -1216,13 +1202,18 @@ describe('DiffsStoreActions', () => {
});
});
- describe('setFileCollapsed', () => {
+ describe('setFileUserCollapsed', () => {
it('commits SET_FILE_COLLAPSED', done => {
testAction(
- setFileCollapsed,
+ setFileCollapsedByUser,
{ filePath: 'test', collapsed: true },
null,
- [{ type: types.SET_FILE_COLLAPSED, payload: { filePath: 'test', collapsed: true } }],
+ [
+ {
+ type: types.SET_FILE_COLLAPSED,
+ payload: { filePath: 'test', collapsed: true, trigger: 'manual' },
+ },
+ ],
[],
done,
);
diff --git a/spec/frontend/diffs/store/getters_spec.js b/spec/frontend/diffs/store/getters_spec.js
index 0083f1d8b44..7e936c561fc 100644
--- a/spec/frontend/diffs/store/getters_spec.js
+++ b/spec/frontend/diffs/store/getters_spec.js
@@ -49,23 +49,53 @@ describe('Diffs Module Getters', () => {
});
});
- describe('hasCollapsedFile', () => {
- it('returns true when all files are collapsed', () => {
- localState.diffFiles = [
- { viewer: { automaticallyCollapsed: true } },
- { viewer: { automaticallyCollapsed: true } },
- ];
+ describe('whichCollapsedTypes', () => {
+ const autoCollapsedFile = { viewer: { automaticallyCollapsed: true, manuallyCollapsed: null } };
+ const manuallyCollapsedFile = {
+ viewer: { automaticallyCollapsed: false, manuallyCollapsed: true },
+ };
+ const openFile = { viewer: { automaticallyCollapsed: false, manuallyCollapsed: false } };
+
+ it.each`
+ description | value | files
+ ${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
+ ${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
+ ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
+ ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
+ `('`any` is $value when $description', ({ value, files }) => {
+ localState.diffFiles = files;
+
+ const getterResult = getters.whichCollapsedTypes(localState);
+
+ expect(getterResult.any).toEqual(value);
+ });
+
+ it.each`
+ description | value | files
+ ${'all files are automatically collapsed'} | ${true} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
+ ${'all files are manually collapsed'} | ${false} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
+ ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
+ ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
+ `('`automatic` is $value when $description', ({ value, files }) => {
+ localState.diffFiles = files;
- expect(getters.hasCollapsedFile(localState)).toEqual(true);
+ const getterResult = getters.whichCollapsedTypes(localState);
+
+ expect(getterResult.automatic).toEqual(value);
});
- it('returns true when at least one file is collapsed', () => {
- localState.diffFiles = [
- { viewer: { automaticallyCollapsed: false } },
- { viewer: { automaticallyCollapsed: true } },
- ];
+ it.each`
+ description | value | files
+ ${'all files are automatically collapsed'} | ${false} | ${[{ ...autoCollapsedFile }, { ...autoCollapsedFile }]}
+ ${'all files are manually collapsed'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...manuallyCollapsedFile }]}
+ ${'no files are collapsed in any way'} | ${false} | ${[{ ...openFile }, { ...openFile }]}
+ ${'some files are collapsed in either way'} | ${true} | ${[{ ...manuallyCollapsedFile }, { ...autoCollapsedFile }, { ...openFile }]}
+ `('`manual` is $value when $description', ({ value, files }) => {
+ localState.diffFiles = files;
+
+ const getterResult = getters.whichCollapsedTypes(localState);
- expect(getters.hasCollapsedFile(localState)).toEqual(true);
+ expect(getterResult.manual).toEqual(value);
});
});
diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js
index a84ad63c695..c0645faf89e 100644
--- a/spec/frontend/diffs/store/mutations_spec.js
+++ b/spec/frontend/diffs/store/mutations_spec.js
@@ -126,21 +126,6 @@ describe('DiffsStoreMutations', () => {
});
});
- describe('EXPAND_ALL_FILES', () => {
- it('should change the collapsed prop from diffFiles', () => {
- const diffFile = {
- viewer: {
- automaticallyCollapsed: true,
- },
- };
- const state = { expandAllFiles: true, diffFiles: [diffFile] };
-
- mutations[types.EXPAND_ALL_FILES](state);
-
- expect(state.diffFiles[0].viewer.automaticallyCollapsed).toEqual(false);
- });
- });
-
describe('ADD_CONTEXT_LINES', () => {
it('should call utils.addContextLines with proper params', () => {
const options = {
diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js
index 39a482c85ae..866be0abd22 100644
--- a/spec/frontend/diffs/store/utils_spec.js
+++ b/spec/frontend/diffs/store/utils_spec.js
@@ -1221,5 +1221,26 @@ describe('DiffsStoreUtils', () => {
file.parallel_diff_lines,
);
});
+
+ /**
+ * What's going on here?
+ *
+ * The inline version of parallelizeDiffLines simply keeps the difflines
+ * in the same order they are received as opposed to shuffling them
+ * to be "side by side".
+ *
+ * This keeps the underlying data structure the same which simplifies
+ * the components, but keeps the changes grouped together as users
+ * expect when viewing changes inline.
+ */
+ it('converts inline diff lines to inline diff lines with a parallel structure', () => {
+ const file = getDiffFileMock();
+ const files = utils.parallelizeDiffLines(file.highlighted_diff_lines, true);
+
+ expect(files[5].left).toEqual(file.parallel_diff_lines[5].left);
+ expect(files[5].right).toBeNull();
+ expect(files[6].left).toBeNull();
+ expect(files[6].right).toEqual(file.parallel_diff_lines[5].right);
+ });
});
});
diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js
index bc17435c6d4..2968984df01 100644
--- a/spec/frontend/editor/editor_lite_spec.js
+++ b/spec/frontend/editor/editor_lite_spec.js
@@ -64,7 +64,7 @@ describe('Base editor', () => {
});
it('creates model to be supplied to Monaco editor', () => {
- editor.createInstance({ el: editorEl, blobPath, blobContent });
+ editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId: '' });
expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, createUri(blobPath));
expect(setModel).toHaveBeenCalledWith(fakeModel);
diff --git a/spec/frontend/environments/environment_delete_spec.js b/spec/frontend/environments/environment_delete_spec.js
index b4ecb24cbac..a8c288a3bd8 100644
--- a/spec/frontend/environments/environment_delete_spec.js
+++ b/spec/frontend/environments/environment_delete_spec.js
@@ -1,11 +1,9 @@
-import $ from 'jquery';
+import { GlButton } from '@gitlab/ui';
+
import { shallowMount } from '@vue/test-utils';
import DeleteComponent from '~/environments/components/environment_delete.vue';
-import LoadingButton from '~/vue_shared/components/loading_button.vue';
import eventHub from '~/environments/event_hub';
-$.fn.tooltip = () => {};
-
describe('External URL Component', () => {
let wrapper;
@@ -17,7 +15,7 @@ describe('External URL Component', () => {
});
};
- const findButton = () => wrapper.find(LoadingButton);
+ const findButton = () => wrapper.find(GlButton);
beforeEach(() => {
jest.spyOn(window, 'confirm');
diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
index 31f355ce6f1..a77bf39cb54 100644
--- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js
+++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js
@@ -1,12 +1,6 @@
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
-import {
- GlEmptyState,
- GlLoadingIcon,
- GlFormInput,
- GlPagination,
- GlDeprecatedDropdown,
-} from '@gitlab/ui';
+import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui';
import stubChildren from 'helpers/stub_children';
import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue';
import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue';
@@ -24,19 +18,19 @@ describe('ErrorTrackingList', () => {
const findErrorListTable = () => wrapper.find('table');
const findErrorListRows = () => wrapper.findAll('tbody tr');
- const dropdownsArray = () => wrapper.findAll(GlDeprecatedDropdown);
+ const dropdownsArray = () => wrapper.findAll(GlDropdown);
const findRecentSearchesDropdown = () =>
dropdownsArray()
.at(0)
- .find(GlDeprecatedDropdown);
+ .find(GlDropdown);
const findStatusFilterDropdown = () =>
dropdownsArray()
.at(1)
- .find(GlDeprecatedDropdown);
+ .find(GlDropdown);
const findSortDropdown = () =>
dropdownsArray()
.at(2)
- .find(GlDeprecatedDropdown);
+ .find(GlDropdown);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPagination = () => wrapper.find(GlPagination);
const findErrorActions = () => wrapper.find(ErrorTrackingActions);
@@ -134,8 +128,8 @@ describe('ErrorTrackingList', () => {
mountComponent({
stubs: {
GlTable: false,
- GlDeprecatedDropdown: false,
- GlDeprecatedDropdownItem: false,
+ GlDropdown: false,
+ GlDropdownItem: false,
GlLink: false,
},
});
@@ -205,8 +199,8 @@ describe('ErrorTrackingList', () => {
mountComponent({
stubs: {
GlTable: false,
- GlDeprecatedDropdown: false,
- GlDeprecatedDropdownItem: false,
+ GlDropdown: false,
+ GlDropdownItem: false,
},
});
});
@@ -325,8 +319,8 @@ describe('ErrorTrackingList', () => {
beforeEach(() => {
mountComponent({
stubs: {
- GlDeprecatedDropdown: false,
- GlDeprecatedDropdownItem: false,
+ GlDropdown: false,
+ GlDropdownItem: false,
},
});
});
diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
index 023a3e26781..d924f895da8 100644
--- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
+++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js
@@ -1,7 +1,7 @@
import { pick, clone } from 'lodash';
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue';
import { defaultProps, projectList, staleProject } from '../mock';
@@ -43,7 +43,7 @@ describe('error tracking settings project dropdown', () => {
describe('empty project list', () => {
it('renders the dropdown', () => {
expect(wrapper.find('#project-dropdown').exists()).toBeTruthy();
- expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy();
+ expect(wrapper.find(GlDropdown).exists()).toBeTruthy();
});
it('shows helper text', () => {
@@ -58,8 +58,8 @@ describe('error tracking settings project dropdown', () => {
});
it('does not contain any dropdown items', () => {
- expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeFalsy();
- expect(wrapper.find(GlDeprecatedDropdown).props('text')).toBe('No projects available');
+ expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy();
+ expect(wrapper.find(GlDropdown).props('text')).toBe('No projects available');
});
});
@@ -72,12 +72,12 @@ describe('error tracking settings project dropdown', () => {
it('renders the dropdown', () => {
expect(wrapper.find('#project-dropdown').exists()).toBeTruthy();
- expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy();
+ expect(wrapper.find(GlDropdown).exists()).toBeTruthy();
});
it('contains a number of dropdown items', () => {
- expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeTruthy();
- expect(wrapper.findAll(GlDeprecatedDropdownItem).length).toBe(2);
+ expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy();
+ expect(wrapper.findAll(GlDropdownItem).length).toBe(2);
});
});
diff --git a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
index 0e364c47f8d..67f4bee766b 100644
--- a/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
+++ b/spec/frontend/feature_flags/components/configure_feature_flags_modal_spec.js
@@ -99,13 +99,13 @@ describe('Configure Feature Flags Modal', () => {
});
it('should display the api URL in an input box', () => {
- const input = wrapper.find('#api_url');
- expect(input.element.value).toBe('/api/url');
+ const input = wrapper.find('#api-url');
+ expect(input.attributes('value')).toBe('/api/url');
});
it('should display the instance ID in an input box', () => {
const input = wrapper.find('#instance_id');
- expect(input.element.value).toBe('instance-id-token');
+ expect(input.attributes('value')).toBe('instance-id-token');
});
});
@@ -129,7 +129,7 @@ describe('Configure Feature Flags Modal', () => {
expect(findPrimaryAction()).toBe(null);
});
- it('shold not display regenerating instance ID', async () => {
+ it('should not display regenerating instance ID', async () => {
expect(findDangerCallout().exists()).toBe(false);
});
diff --git a/spec/frontend/feature_flags/components/form_spec.js b/spec/frontend/feature_flags/components/form_spec.js
index 33c7eeb54b7..2c2a726d26f 100644
--- a/spec/frontend/feature_flags/components/form_spec.js
+++ b/spec/frontend/feature_flags/components/form_spec.js
@@ -442,12 +442,6 @@ describe('feature flag form', () => {
});
});
- it('should request the user lists on mount', () => {
- return wrapper.vm.$nextTick(() => {
- expect(Api.fetchFeatureFlagUserLists).toHaveBeenCalledWith('1');
- });
- });
-
it('should show the strategy component', () => {
const strategy = wrapper.find(Strategy);
expect(strategy.exists()).toBe(true);
@@ -485,9 +479,5 @@ describe('feature flag form', () => {
expect(wrapper.find(Strategy).props('strategy')).not.toEqual(strategy);
});
});
-
- it('should provide the user lists to the strategy', () => {
- expect(wrapper.find(Strategy).props('userLists')).toEqual([userList]);
- });
});
});
diff --git a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
index f3f70a325d0..725f53d4409 100644
--- a/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/flexible_rollout_spec.js
@@ -100,7 +100,7 @@ describe('feature_flags/components/strategies/flexible_rollout.vue', () => {
});
});
- describe('with percentage that is not a whole number', () => {
+ describe('with percentage that is not an integer number', () => {
beforeEach(() => {
wrapper = factory({ strategy: { parameters: { rollout: '3.14' } } });
});
diff --git a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
index 014c6dd98b9..b34fe7779e3 100644
--- a/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/gitlab_user_list_spec.js
@@ -1,51 +1,103 @@
-import { mount } from '@vue/test-utils';
-import { GlFormSelect } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import createStore from '~/feature_flags/store/new';
import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_list.vue';
import { userListStrategy, userList } from '../../mock_data';
+jest.mock('~/api');
+
const DEFAULT_PROPS = {
strategy: userListStrategy,
- userLists: [userList],
};
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('~/feature_flags/components/strategies/gitlab_user_list.vue', () => {
let wrapper;
const factory = (props = {}) =>
- mount(GitlabUserList, { propsData: { ...DEFAULT_PROPS, ...props } });
+ mount(GitlabUserList, {
+ localVue,
+ store: createStore({ projectId: '1' }),
+ propsData: { ...DEFAULT_PROPS, ...props },
+ });
+
+ const findDropdown = () => wrapper.find(GlDropdown);
describe('with user lists', () => {
+ const findDropdownItem = () => wrapper.find(GlDropdownItem);
+
beforeEach(() => {
+ Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
wrapper = factory();
});
it('should show the input for userListId with the correct value', () => {
- const inputWrapper = wrapper.find(GlFormSelect);
- expect(inputWrapper.exists()).toBe(true);
- expect(inputWrapper.element.value).toBe('2');
+ const dropdownWrapper = findDropdown();
+ expect(dropdownWrapper.exists()).toBe(true);
+ expect(dropdownWrapper.props('text')).toBe(userList.name);
+ });
+
+ it('should show a check for the selected list', () => {
+ const itemWrapper = findDropdownItem();
+ expect(itemWrapper.props('isChecked')).toBe(true);
+ });
+
+ it('should display the name of the list in the drop;down', () => {
+ const itemWrapper = findDropdownItem();
+ expect(itemWrapper.text()).toBe(userList.name);
});
it('should emit a change event when altering the userListId', () => {
- const inputWrapper = wrapper.find(GitlabUserList);
- inputWrapper.vm.$emit('change', {
- userListId: '3',
- });
+ const inputWrapper = findDropdownItem();
+ inputWrapper.vm.$emit('click');
expect(wrapper.emitted('change')).toEqual([
[
{
- userListId: '3',
+ userList,
},
],
]);
});
+
+ it('should search when the filter changes', async () => {
+ let r;
+ Api.searchFeatureFlagUserLists.mockReturnValue(
+ new Promise(resolve => {
+ r = resolve;
+ }),
+ );
+ const searchWrapper = wrapper.find(GlSearchBoxByType);
+ searchWrapper.vm.$emit('input', 'new');
+ await wrapper.vm.$nextTick();
+ const loadingIcon = wrapper.find(GlLoadingIcon);
+
+ expect(loadingIcon.exists()).toBe(true);
+ expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'new');
+
+ r({ data: [userList] });
+
+ await wrapper.vm.$nextTick();
+
+ expect(loadingIcon.exists()).toBe(false);
+ });
});
+
describe('without user lists', () => {
beforeEach(() => {
- wrapper = factory({ userLists: [] });
+ Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [] });
+ wrapper = factory({ strategy: { ...userListStrategy, userList: null } });
});
it('should display a message that there are no user lists', () => {
expect(wrapper.text()).toContain('There are no configured user lists');
});
+
+ it('should dispaly a message that no list has been selected', () => {
+ expect(findDropdown().text()).toContain('No user list selected');
+ });
});
});
diff --git a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
index de0b439f1c5..696b3b2e4c9 100644
--- a/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
+++ b/spec/frontend/feature_flags/components/strategies/percent_rollout_spec.js
@@ -63,7 +63,7 @@ describe('~/feature_flags/components/strategies/percent_rollout.vue', () => {
});
});
- describe('with percentage that is not a whole number', () => {
+ describe('with percentage that is not an integer number', () => {
beforeEach(() => {
wrapper = factory({ strategy: { parameters: { percentage: '3.14' } } });
diff --git a/spec/frontend/feature_flags/components/strategy_parameters_spec.js b/spec/frontend/feature_flags/components/strategy_parameters_spec.js
index 314fb0f21f4..a024384e623 100644
--- a/spec/frontend/feature_flags/components/strategy_parameters_spec.js
+++ b/spec/frontend/feature_flags/components/strategy_parameters_spec.js
@@ -11,11 +11,10 @@ import GitlabUserList from '~/feature_flags/components/strategies/gitlab_user_li
import PercentRollout from '~/feature_flags/components/strategies/percent_rollout.vue';
import UsersWithId from '~/feature_flags/components/strategies/users_with_id.vue';
import StrategyParameters from '~/feature_flags/components/strategy_parameters.vue';
-import { allUsersStrategy, userList } from '../mock_data';
+import { allUsersStrategy } from '../mock_data';
const DEFAULT_PROPS = {
strategy: allUsersStrategy,
- userLists: [userList],
};
describe('~/feature_flags/components/strategy_parameters.vue', () => {
@@ -71,13 +70,14 @@ describe('~/feature_flags/components/strategy_parameters.vue', () => {
describe('pass through props', () => {
it('should pass through any extra props that might be needed', () => {
+ const strategy = {
+ name: ROLLOUT_STRATEGY_USER_ID,
+ };
wrapper = factory({
- strategy: {
- name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
- },
+ strategy,
});
- expect(wrapper.find(GitlabUserList).props('userLists')).toEqual([userList]);
+ expect(wrapper.find(UsersWithId).props('strategy')).toEqual(strategy);
});
});
});
diff --git a/spec/frontend/feature_flags/components/strategy_spec.js b/spec/frontend/feature_flags/components/strategy_spec.js
index 7d6700ba184..67cf70c37e2 100644
--- a/spec/frontend/feature_flags/components/strategy_spec.js
+++ b/spec/frontend/feature_flags/components/strategy_spec.js
@@ -1,6 +1,9 @@
-import { mount } from '@vue/test-utils';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
import { last } from 'lodash';
import { GlAlert, GlFormSelect, GlLink, GlToken, GlButton } from '@gitlab/ui';
+import Api from '~/api';
+import createStore from '~/feature_flags/store/new';
import {
PERCENT_ROLLOUT_GROUP_ID,
ROLLOUT_STRATEGY_ALL_USERS,
@@ -15,12 +18,17 @@ import StrategyParameters from '~/feature_flags/components/strategy_parameters.v
import { userList } from '../mock_data';
+jest.mock('~/api');
+
const provide = {
strategyTypeDocsPagePath: 'link-to-strategy-docs',
environmentsScopeDocsPath: 'link-scope-docs',
environmentsEndpoint: '',
};
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
describe('Feature flags strategy', () => {
let wrapper;
@@ -32,7 +40,6 @@ describe('Feature flags strategy', () => {
propsData: {
strategy: {},
index: 0,
- userLists: [userList],
},
provide,
},
@@ -41,9 +48,13 @@ describe('Feature flags strategy', () => {
wrapper.destroy();
wrapper = null;
}
- wrapper = mount(Strategy, opts);
+ wrapper = mount(Strategy, { localVue, store: createStore({ projectId: '1' }), ...opts });
};
+ beforeEach(() => {
+ Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
+ });
+
afterEach(() => {
if (wrapper) {
wrapper.destroy();
diff --git a/spec/frontend/feature_flags/mock_data.js b/spec/frontend/feature_flags/mock_data.js
index ed06ea059a7..11a91e5b2a8 100644
--- a/spec/frontend/feature_flags/mock_data.js
+++ b/spec/frontend/feature_flags/mock_data.js
@@ -127,7 +127,7 @@ export const userListStrategy = {
name: ROLLOUT_STRATEGY_GITLAB_USER_LIST,
parameters: {},
scopes: [],
- userListId: userList.id,
+ userList,
};
export const percentRolloutStrategy = {
diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js
new file mode 100644
index 00000000000..aba578cca59
--- /dev/null
+++ b/spec/frontend/feature_flags/store/gitlab_user_lists/actions_spec.js
@@ -0,0 +1,60 @@
+import testAction from 'helpers/vuex_action_helper';
+import Api from '~/api';
+import createState from '~/feature_flags/store/gitlab_user_list/state';
+import { fetchUserLists, setFilter } from '~/feature_flags/store/gitlab_user_list/actions';
+import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types';
+import { userList } from '../../mock_data';
+
+jest.mock('~/api');
+
+describe('~/feature_flags/store/gitlab_user_list/actions', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = createState({ projectId: '1' });
+ mockedState.filter = 'test';
+ });
+
+ describe('fetchUserLists', () => {
+ it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_SUCCESS on success', () => {
+ Api.searchFeatureFlagUserLists.mockResolvedValue({ data: [userList] });
+ return testAction(
+ fetchUserLists,
+ undefined,
+ mockedState,
+ [
+ { type: types.FETCH_USER_LISTS },
+ { type: types.RECEIVE_USER_LISTS_SUCCESS, payload: [userList] },
+ ],
+ [],
+ () => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'),
+ );
+ });
+
+ it('should commit FETCH_USER_LISTS and RECEIEVE_USER_LISTS_ERROR on success', () => {
+ Api.searchFeatureFlagUserLists.mockRejectedValue({ message: 'error' });
+ return testAction(
+ fetchUserLists,
+ undefined,
+ mockedState,
+ [
+ { type: types.FETCH_USER_LISTS },
+ { type: types.RECEIVE_USER_LISTS_ERROR, payload: ['error'] },
+ ],
+ [],
+ () => expect(Api.searchFeatureFlagUserLists).toHaveBeenCalledWith('1', 'test'),
+ );
+ });
+ });
+
+ describe('setFilter', () => {
+ it('commits SET_FILTER and fetches new user lists', () =>
+ testAction(
+ setFilter,
+ 'filter',
+ mockedState,
+ [{ type: types.SET_FILTER, payload: 'filter' }],
+ [{ type: 'fetchUserLists' }],
+ ));
+ });
+});
diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js
new file mode 100644
index 00000000000..e267cd59f50
--- /dev/null
+++ b/spec/frontend/feature_flags/store/gitlab_user_lists/getters_spec.js
@@ -0,0 +1,69 @@
+import {
+ userListOptions,
+ hasUserLists,
+ isLoading,
+ hasError,
+} from '~/feature_flags/store/gitlab_user_list/getters';
+import statuses from '~/feature_flags/store/gitlab_user_list/status';
+import createState from '~/feature_flags/store/gitlab_user_list/state';
+import { userList } from '../../mock_data';
+
+describe('~/feature_flags/store/gitlab_user_list/getters', () => {
+ let mockedState;
+
+ beforeEach(() => {
+ mockedState = createState({ projectId: '8' });
+ mockedState.userLists = [userList];
+ });
+
+ describe('userListOption', () => {
+ it('should return user lists in a way usable by a dropdown', () => {
+ expect(userListOptions(mockedState)).toEqual([{ value: userList.id, text: userList.name }]);
+ });
+
+ it('should return an empty array if there are no lists', () => {
+ mockedState.userLists = [];
+ expect(userListOptions(mockedState)).toEqual([]);
+ });
+ });
+
+ describe('hasUserLists', () => {
+ it.each`
+ userLists | status | result
+ ${[userList]} | ${statuses.IDLE} | ${true}
+ ${[]} | ${statuses.IDLE} | ${false}
+ ${[]} | ${statuses.START} | ${true}
+ `(
+ 'should return $result if there are $userLists.length user lists and the status is $status',
+ ({ userLists, status, result }) => {
+ mockedState.userLists = userLists;
+ mockedState.status = status;
+ expect(hasUserLists(mockedState)).toBe(result);
+ },
+ );
+ });
+
+ describe('isLoading', () => {
+ it.each`
+ status | result
+ ${statuses.LOADING} | ${true}
+ ${statuses.ERROR} | ${false}
+ ${statuses.IDLE} | ${false}
+ `('should return $result if the status is "$status"', ({ status, result }) => {
+ mockedState.status = status;
+ expect(isLoading(mockedState)).toBe(result);
+ });
+ });
+
+ describe('hasError', () => {
+ it.each`
+ status | result
+ ${statuses.LOADING} | ${false}
+ ${statuses.ERROR} | ${true}
+ ${statuses.IDLE} | ${false}
+ `('should return $result if the status is "$status"', ({ status, result }) => {
+ mockedState.status = status;
+ expect(hasError(mockedState)).toBe(result);
+ });
+ });
+});
diff --git a/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js b/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js
new file mode 100644
index 00000000000..88d4554a227
--- /dev/null
+++ b/spec/frontend/feature_flags/store/gitlab_user_lists/mutations_spec.js
@@ -0,0 +1,50 @@
+import statuses from '~/feature_flags/store/gitlab_user_list/status';
+import createState from '~/feature_flags/store/gitlab_user_list/state';
+import * as types from '~/feature_flags/store/gitlab_user_list/mutation_types';
+import mutations from '~/feature_flags/store/gitlab_user_list/mutations';
+import { userList } from '../../mock_data';
+
+describe('~/feature_flags/store/gitlab_user_list/mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ projectId: '8' });
+ });
+
+ describe(types.SET_FILTER, () => {
+ it('sets the filter in the state', () => {
+ mutations[types.SET_FILTER](state, 'test');
+ expect(state.filter).toBe('test');
+ });
+ });
+
+ describe(types.FETCH_USER_LISTS, () => {
+ it('sets the status to loading', () => {
+ mutations[types.FETCH_USER_LISTS](state);
+ expect(state.status).toBe(statuses.LOADING);
+ });
+ });
+
+ describe(types.RECEIVE_USER_LISTS_SUCCESS, () => {
+ it('sets the user lists to the ones received', () => {
+ mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]);
+ expect(state.userLists).toEqual([userList]);
+ });
+
+ it('sets the status to idle', () => {
+ mutations[types.RECEIVE_USER_LISTS_SUCCESS](state, [userList]);
+ expect(state.status).toBe(statuses.IDLE);
+ });
+ });
+ describe(types.RECEIVE_USER_LISTS_ERROR, () => {
+ it('sets the status to error', () => {
+ mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure');
+ expect(state.status).toBe(statuses.ERROR);
+ });
+
+ it('sets the error message', () => {
+ mutations[types.RECEIVE_USER_LISTS_ERROR](state, 'failure');
+ expect(state.error).toBe('failure');
+ });
+ });
+});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 53c726a6cea..5c37d986ef1 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -1,3 +1,5 @@
+import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager';
+
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';
@@ -5,7 +7,6 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered
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';
diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb
index 7695dbc2e8f..193bd0c3ef2 100644
--- a/spec/frontend/fixtures/freeze_period.rb
+++ b/spec/frontend/fixtures/freeze_period.rb
@@ -17,6 +17,15 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do
remove_repository(project)
end
+ around do |example|
+ freeze_time do
+ # Mock time to sept 19 (intl. talk like a pirate day)
+ Timecop.travel(2020, 9, 19)
+
+ example.run
+ end
+ end
+
describe API::FreezePeriods, '(JavaScript fixtures)', type: :request do
include ApiHelpers
diff --git a/spec/frontend/fixtures/groups.rb b/spec/frontend/fixtures/groups.rb
index 6f0d7aa1f7c..9f0b2c73c93 100644
--- a/spec/frontend/fixtures/groups.rb
+++ b/spec/frontend/fixtures/groups.rb
@@ -15,7 +15,6 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do
end
before do
- stub_feature_flags(new_variables_ui: false)
group.add_maintainer(admin)
sign_in(admin)
end
@@ -27,12 +26,4 @@ RSpec.describe 'Groups (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
-
- describe Groups::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
- it 'groups/ci_cd_settings.html' do
- get :show, params: { group_id: group }
-
- expect(response).to be_successful
- end
- end
end
diff --git a/spec/frontend/fixtures/issues.rb b/spec/frontend/fixtures/issues.rb
index 2c380ba6a96..baea87be45f 100644
--- a/spec/frontend/fixtures/issues.rb
+++ b/spec/frontend/fixtures/issues.rb
@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end
before do
+ stub_feature_flags(vue_issue_header: false)
+
sign_in(admin)
end
diff --git a/spec/frontend/fixtures/projects.rb b/spec/frontend/fixtures/projects.rb
index d33909fb98b..d0cedb0ef86 100644
--- a/spec/frontend/fixtures/projects.rb
+++ b/spec/frontend/fixtures/projects.rb
@@ -20,7 +20,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
end
before do
- stub_feature_flags(new_variables_ui: false)
project.add_maintainer(admin)
sign_in(admin)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
@@ -58,27 +57,4 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful
end
end
-
- describe Projects::Settings::CiCdController, '(JavaScript fixtures)', type: :controller do
- it 'projects/ci_cd_settings.html' do
- get :show, params: {
- namespace_id: project.namespace.to_param,
- project_id: project
- }
-
- expect(response).to be_successful
- end
-
- it 'projects/ci_cd_settings_with_variables.html' do
- create(:ci_variable, project: project_variable_populated)
- create(:ci_variable, project: project_variable_populated)
-
- get :show, params: {
- namespace_id: project_variable_populated.namespace.to_param,
- project_id: project_variable_populated
- }
-
- expect(response).to be_successful
- end
- end
end
diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb
index 40f613a9422..7819d0774a7 100644
--- a/spec/frontend/fixtures/search.rb
+++ b/spec/frontend/fixtures/search.rb
@@ -26,8 +26,51 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do
context 'search within a project' do
let(:namespace) { create(:namespace, name: 'frontend-fixtures') }
let(:project) { create(:project, :public, :repository, namespace: namespace, path: 'search-project') }
+ let(:blobs) do
+ Kaminari.paginate_array([
+ Gitlab::Search::FoundBlob.new(
+ path: 'CHANGELOG',
+ basename: 'CHANGELOG',
+ ref: 'master',
+ data: "hello\nworld\nfoo\nSend # this is the highligh\nbaz\nboo\nbat",
+ project: project,
+ project_id: project.id,
+ startline: 2),
+ Gitlab::Search::FoundBlob.new(
+ path: 'CONTRIBUTING',
+ basename: 'CONTRIBUTING',
+ ref: 'master',
+ data: "hello\nworld\nfoo\nSend # this is the highligh\nbaz\nboo\nbat",
+ project: project,
+ project_id: project.id,
+ startline: 2),
+ Gitlab::Search::FoundBlob.new(
+ path: 'README',
+ basename: 'README',
+ ref: 'master',
+ data: "foo\nSend # this is the highlight\nbaz\nboo\nbat",
+ project: project,
+ project_id: project.id,
+ startline: 2),
+ Gitlab::Search::FoundBlob.new(
+ path: 'test',
+ basename: 'test',
+ ref: 'master',
+ data: "foo\nSend # this is the highlight\nbaz\nboo\nbat",
+ project: project,
+ project_id: project.id,
+ startline: 2)
+ ],
+ total_count: 4,
+ limit: 4,
+ offset: 0)
+ end
it 'search/blob_search_result.html' do
+ expect_next_instance_of(SearchService) do |search_service|
+ expect(search_service).to receive(:search_objects).and_return(blobs)
+ end
+
get :show, params: {
search: 'Send',
project_id: project.id,
diff --git a/spec/frontend/fixtures/static/signin_tabs.html b/spec/frontend/fixtures/static/signin_tabs.html
index 247a6b03054..7e66ab9394b 100644
--- a/spec/frontend/fixtures/static/signin_tabs.html
+++ b/spec/frontend/fixtures/static/signin_tabs.html
@@ -5,7 +5,4 @@
<li>
<a href="#login-pane">Standard</a>
</li>
-<li>
-<a href="#register-pane">Register</a>
-</li>
</ul>
diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js
index 8da4320d993..eb9343847f1 100644
--- a/spec/frontend/gfm_auto_complete_spec.js
+++ b/spec/frontend/gfm_auto_complete_spec.js
@@ -8,15 +8,226 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
+import waitForPromises from 'jest/helpers/wait_for_promises';
+
+import MockAdapter from 'axios-mock-adapter';
+import AjaxCache from '~/lib/utils/ajax_cache';
+import axios from '~/lib/utils/axios_utils';
+
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
describe('GfmAutoComplete', () => {
- const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
- fetchData: () => {},
- });
+ const fetchDataMock = { fetchData: jest.fn() };
+ let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
let atwhoInstance;
let sorterValue;
+ let filterValue;
+
+ describe('DefaultOptions.filter', () => {
+ let items;
+
+ beforeEach(() => {
+ jest.spyOn(fetchDataMock, 'fetchData');
+ jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {});
+ });
+
+ describe('assets loading', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' };
+ items = ['loading'];
+
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items);
+ });
+
+ it('should call the fetchData function without query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '[vulnerability:');
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('backend filtering', () => {
+ beforeEach(() => {
+ atwhoInstance = { setting: {}, $inputor: 'inputor', at: '[vulnerability:' };
+ items = [];
+ });
+
+ describe('when previous query is different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items);
+ });
+
+ it('should call the fetchData function with query', () => {
+ expect(fetchDataMock.fetchData).toHaveBeenCalledWith(
+ 'inputor',
+ '[vulnerability:',
+ 'newquery',
+ );
+ });
+
+ it('should not call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
+ });
+
+ it('should return the passed unfiltered items', () => {
+ expect(filterValue).toEqual(items);
+ });
+ });
+
+ describe('when previous query is not different from current one', () => {
+ beforeEach(() => {
+ gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
+ previousQuery: 'oldquery',
+ ...fetchDataMock,
+ });
+ filterValue = gfmAutoCompleteCallbacks.filter.call(
+ atwhoInstance,
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+
+ it('should not call the fetchData function', () => {
+ expect(fetchDataMock.fetchData).not.toHaveBeenCalled();
+ });
+
+ it('should call the default atwho filter', () => {
+ expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith(
+ 'oldquery',
+ items,
+ 'searchKey',
+ );
+ });
+ });
+ });
+ });
+
+ describe('fetchData', () => {
+ const { fetchData } = GfmAutoComplete.prototype;
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ jest.spyOn(axios, 'get');
+ jest.spyOn(AjaxCache, 'retrieve');
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('already loading data', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '[vulnerability:': true },
+ dataSources: {},
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '[vulnerability:', '');
+ });
+
+ it('should not call either axios nor AjaxCache', () => {
+ expect(axios.get).not.toHaveBeenCalled();
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('backend filtering', () => {
+ describe('data is not in cache', () => {
+ let context;
+
+ beforeEach(() => {
+ context = {
+ isLoadingData: { '[vulnerability:': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: {},
+ };
+ });
+
+ it('should call axios with query', () => {
+ fetchData.call(context, {}, '[vulnerability:', 'query');
+
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+
+ it.each([200, 500])('should set the loading state', async responseStatus => {
+ mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus);
+
+ fetchData.call(context, {}, '[vulnerability:', 'query');
+
+ expect(context.isLoadingData['[vulnerability:']).toBe(true);
+
+ await waitForPromises();
+
+ expect(context.isLoadingData['[vulnerability:']).toBe(false);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '[vulnerability:': false },
+ dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
+ cachedData: { '[vulnerability:': [{}] },
+ };
+ fetchData.call(context, {}, '[vulnerability:', 'query');
+ });
+
+ it('should anyway call axios with query ignoring cache', () => {
+ expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
+ params: { search: 'query' },
+ });
+ });
+ });
+ });
+
+ describe('frontend filtering', () => {
+ describe('data is not in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should call AjaxCache', () => {
+ expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true);
+ });
+ });
+
+ describe('data is in cache', () => {
+ beforeEach(() => {
+ const context = {
+ isLoadingData: { '#': false },
+ dataSources: { issues: 'issues_autocomplete_url' },
+ cachedData: { '#': [{}] },
+ loadData: () => {},
+ };
+ fetchData.call(context, {}, '#', 'query');
+ });
+
+ it('should not call AjaxCache', () => {
+ expect(AjaxCache.retrieve).not.toHaveBeenCalled();
+ });
+ });
+ });
+ });
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
@@ -154,7 +365,6 @@ describe('GfmAutoComplete', () => {
'я',
'.',
"'",
- '+',
'-',
'_',
];
@@ -378,6 +588,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: '',
icon: '',
+ availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
@@ -389,6 +600,7 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
+ availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
});
@@ -400,9 +612,24 @@ describe('GfmAutoComplete', () => {
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
+ availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
});
+
+ it('should add user availability status if availabilityStatus is set', () => {
+ expect(
+ GfmAutoComplete.Members.templateFunction({
+ avatarTag: 'IMG',
+ username: 'my-group',
+ title: '',
+ icon: '<i class="icon"/>',
+ availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
+ }),
+ ).toBe(
+ '<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
+ );
+ });
});
describe('labels', () => {
diff --git a/spec/frontend/graphql_shared/utils_spec.js b/spec/frontend/graphql_shared/utils_spec.js
index 52386bf6ede..6a630195126 100644
--- a/spec/frontend/graphql_shared/utils_spec.js
+++ b/spec/frontend/graphql_shared/utils_spec.js
@@ -11,6 +11,10 @@ describe('getIdFromGraphQLId', () => {
output: null,
},
{
+ input: 2,
+ output: 2,
+ },
+ {
input: 'gid://',
output: null,
},
diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js
index 5d34bc48ed5..691f8896d74 100644
--- a/spec/frontend/groups/components/app_spec.js
+++ b/spec/frontend/groups/components/app_spec.js
@@ -2,6 +2,8 @@ import '~/flash';
import $ from 'jquery';
import Vue from 'vue';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { GlModal, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import appComponent from '~/groups/components/app.vue';
@@ -23,47 +25,51 @@ import {
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 $toast = {
+ show: jest.fn(),
};
describe('AppComponent', () => {
+ let wrapper;
let vm;
let mock;
let getGroupsSpy;
+ const store = new GroupsStore(false);
+ const service = new GroupsService(mockEndpoint);
+
+ const createShallowComponent = (hideProjects = false) => {
+ store.state.pageInfo = mockPageInfo;
+ wrapper = shallowMount(appComponent, {
+ propsData: {
+ store,
+ service,
+ hideProjects,
+ },
+ mocks: {
+ $toast,
+ },
+ });
+ vm = wrapper.vm;
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
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();
+ createShallowComponent();
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(() => {});
@@ -88,14 +94,6 @@ describe('AppComponent', () => {
});
describe('methods', () => {
- beforeEach(() => {
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
describe('fetchGroups', () => {
it('should call `getGroups` with all the params provided', () => {
return vm
@@ -284,29 +282,15 @@ describe('AppComponent', () => {
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;
@@ -324,18 +308,16 @@ describe('AppComponent', () => {
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');
+ expect($toast.show).toHaveBeenCalledWith(notice);
});
});
@@ -417,8 +399,7 @@ describe('AppComponent', () => {
it('should bind event listeners on eventHub', () => {
jest.spyOn(eventHub, '$on').mockImplementation(() => {});
- const newVm = createComponent();
- newVm.$mount();
+ createShallowComponent();
return vm.$nextTick().then(() => {
expect(eventHub.$on).toHaveBeenCalledWith('fetchPage', expect.any(Function));
@@ -426,25 +407,20 @@ describe('AppComponent', () => {
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();
+ createShallowComponent();
return vm.$nextTick().then(() => {
- expect(newVm.searchEmptyMessage).toBe('No groups or projects matched your search');
- newVm.$destroy();
+ expect(vm.searchEmptyMessage).toBe('No groups or projects matched your search');
});
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', () => {
- const newVm = createComponent(true);
- newVm.$mount();
+ createShallowComponent(true);
return vm.$nextTick().then(() => {
- expect(newVm.searchEmptyMessage).toBe('No groups matched your search');
- newVm.$destroy();
+ expect(vm.searchEmptyMessage).toBe('No groups matched your search');
});
});
});
@@ -453,9 +429,8 @@ describe('AppComponent', () => {
it('should unbind event listeners on eventHub', () => {
jest.spyOn(eventHub, '$off').mockImplementation(() => {});
- const newVm = createComponent();
- newVm.$mount();
- newVm.$destroy();
+ createShallowComponent();
+ wrapper.destroy();
return vm.$nextTick().then(() => {
expect(eventHub.$off).toHaveBeenCalledWith('fetchPage', expect.any(Function));
@@ -468,19 +443,10 @@ describe('AppComponent', () => {
});
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');
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
});
@@ -493,15 +459,13 @@ describe('AppComponent', () => {
});
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');
+ createShallowComponent();
- 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');
- });
+ const findGlModal = wrapper.find(GlModal);
+
+ expect(findGlModal.exists()).toBe(true);
+ expect(findGlModal.attributes('title')).toBe('Are you sure?');
+ expect(findGlModal.props('actionPrimary').text).toBe('Leave group');
});
});
});
diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js
index d4aa29eaadd..9adbc9abe13 100644
--- a/spec/frontend/groups/components/item_actions_spec.js
+++ b/spec/frontend/groups/components/item_actions_spec.js
@@ -1,5 +1,4 @@
import { shallowMount } from '@vue/test-utils';
-import { GlIcon } from '@gitlab/ui';
import ItemActions from '~/groups/components/item_actions.vue';
import eventHub from '~/groups/event_hub';
import { mockParentGroupItem, mockChildren } from '../mock_data';
@@ -20,18 +19,25 @@ describe('ItemActions', () => {
};
afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- wrapper = null;
- }
+ wrapper.destroy();
+ wrapper = null;
});
const findEditGroupBtn = () => wrapper.find('[data-testid="edit-group-btn"]');
- const findEditGroupIcon = () => findEditGroupBtn().find(GlIcon);
const findLeaveGroupBtn = () => wrapper.find('[data-testid="leave-group-btn"]');
- const findLeaveGroupIcon = () => findLeaveGroupBtn().find(GlIcon);
describe('template', () => {
+ let group;
+
+ beforeEach(() => {
+ group = {
+ ...mockParentGroupItem,
+ canEdit: true,
+ canLeave: true,
+ };
+ createComponent({ group });
+ });
+
it('renders component template correctly', () => {
createComponent();
@@ -39,49 +45,46 @@ describe('ItemActions', () => {
});
it('renders "Edit group" button with correct attribute values', () => {
- const group = {
- ...mockParentGroupItem,
- canEdit: true,
- };
-
- createComponent({ group });
-
- expect(findEditGroupBtn().exists()).toBe(true);
- expect(findEditGroupBtn().classes()).toContain('no-expand');
- expect(findEditGroupBtn().attributes('href')).toBe(group.editPath);
- expect(findEditGroupBtn().attributes('aria-label')).toBe('Edit group');
- expect(findEditGroupBtn().attributes('data-original-title')).toBe('Edit group');
- expect(findEditGroupIcon().exists()).toBe(true);
- expect(findEditGroupIcon().props('name')).toBe('settings');
+ const button = findEditGroupBtn();
+ expect(button.exists()).toBe(true);
+ expect(button.props('icon')).toBe('pencil');
+ expect(button.attributes('aria-label')).toBe('Edit group');
});
- describe('`canLeave` is true', () => {
- const group = {
- ...mockParentGroupItem,
- canLeave: true,
- };
+ it('renders "Leave this group" button with correct attribute values', () => {
+ const button = findLeaveGroupBtn();
+ expect(button.exists()).toBe(true);
+ expect(button.props('icon')).toBe('leave');
+ expect(button.attributes('aria-label')).toBe('Leave this group');
+ });
- beforeEach(() => {
- createComponent({ group });
- });
+ it('emits `showLeaveGroupModal` event in the event hub', () => {
+ jest.spyOn(eventHub, '$emit');
+ findLeaveGroupBtn().vm.$emit('click', { stopPropagation: () => {} });
- it('renders "Leave this group" button with correct attribute values', () => {
- expect(findLeaveGroupBtn().exists()).toBe(true);
- expect(findLeaveGroupBtn().classes()).toContain('no-expand');
- expect(findLeaveGroupBtn().attributes('href')).toBe(group.leavePath);
- expect(findLeaveGroupBtn().attributes('aria-label')).toBe('Leave this group');
- expect(findLeaveGroupBtn().attributes('data-original-title')).toBe('Leave this group');
- expect(findLeaveGroupIcon().exists()).toBe(true);
- expect(findLeaveGroupIcon().props('name')).toBe('leave');
- });
+ expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup);
+ });
+ });
- it('emits event on "Leave this group" button click', () => {
- jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
+ it('does not render leave button if group can not be left', () => {
+ createComponent({
+ group: {
+ ...mockParentGroupItem,
+ canLeave: false,
+ },
+ });
- findLeaveGroupBtn().trigger('click');
+ expect(findLeaveGroupBtn().exists()).toBe(false);
+ });
- expect(eventHub.$emit).toHaveBeenCalledWith('showLeaveGroupModal', group, parentGroup);
- });
+ it('does not render edit button if group can not be edited', () => {
+ createComponent({
+ group: {
+ ...mockParentGroupItem,
+ canEdit: false,
+ },
});
+
+ expect(findEditGroupBtn().exists()).toBe(false);
});
});
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
index 2fb7904bcfe..aaa36665c45 100644
--- a/spec/frontend/groups/members/index_spec.js
+++ b/spec/frontend/groups/members/index_spec.js
@@ -9,7 +9,12 @@ describe('initGroupMembersApp', () => {
let wrapper;
const setup = () => {
- vm = initGroupMembersApp(el, ['account'], () => ({}));
+ vm = initGroupMembersApp(
+ el,
+ ['account'],
+ { table: { 'data-qa-selector': 'members_list' } },
+ () => ({}),
+ );
wrapper = createWrapper(vm);
};
@@ -68,6 +73,12 @@ describe('initGroupMembersApp', () => {
expect(vm.$store.state.tableFields).toEqual(['account']);
});
+ it('sets `tableAttrs` in Vuex store', () => {
+ setup();
+
+ expect(vm.$store.state.tableAttrs).toEqual({ table: { 'data-qa-selector': 'members_list' } });
+ });
+
it('sets `requestFormatter` in Vuex store', () => {
setup();
diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js
index 380dda9f7b1..603cb27deec 100644
--- a/spec/frontend/groups/mock_data.js
+++ b/spec/frontend/groups/mock_data.js
@@ -7,13 +7,14 @@ export const ITEM_TYPE = {
export const GROUP_VISIBILITY_TYPE = {
public: 'Public - The group and any public projects can be viewed without any authentication.',
- internal: 'Internal - The group and any internal projects can be viewed by any logged in user.',
+ internal:
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
private: 'Private - The group and its projects can only be viewed by members.',
};
export const PROJECT_VISIBILITY_TYPE = {
public: 'Public - The project can be accessed without any authentication.',
- internal: 'Internal - The project can be accessed by any logged in user.',
+ internal: 'Internal - The project can be accessed by any logged in user except external users.',
private:
'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
};
diff --git a/spec/frontend/helpers/fake_date.js b/spec/frontend/helpers/fake_date.js
index 8417b1c520a..387747ab5bd 100644
--- a/spec/frontend/helpers/fake_date.js
+++ b/spec/frontend/helpers/fake_date.js
@@ -15,7 +15,7 @@ export const createFakeDateClass = ctorDefault => {
apply: (target, thisArg, argArray) => {
const ctorArgs = argArray.length ? argArray : ctorDefault;
- return RealDate(...ctorArgs);
+ return new RealDate(...ctorArgs).toString();
},
// We want to overwrite the default 'now', but only if it's not already mocked
get: (target, prop) => {
diff --git a/spec/frontend/helpers/fake_date_spec.js b/spec/frontend/helpers/fake_date_spec.js
index 8afc8225f9b..b3ed13e238a 100644
--- a/spec/frontend/helpers/fake_date_spec.js
+++ b/spec/frontend/helpers/fake_date_spec.js
@@ -13,13 +13,17 @@ describe('spec/helpers/fake_date', () => {
});
it('should use default args', () => {
- expect(new FakeDate()).toEqual(new Date(...DEFAULT_ARGS));
- expect(FakeDate()).toEqual(Date(...DEFAULT_ARGS));
+ expect(new FakeDate()).toMatchInlineSnapshot(`2020-07-06T00:00:00.000Z`);
+ });
+
+ it('should use default args when called as a function', () => {
+ expect(FakeDate()).toMatchInlineSnapshot(
+ `"Mon Jul 06 2020 00:00:00 GMT+0000 (Greenwich Mean Time)"`,
+ );
});
it('should have deterministic now()', () => {
- expect(FakeDate.now()).not.toBe(Date.now());
- expect(FakeDate.now()).toBe(new Date(...DEFAULT_ARGS).getTime());
+ expect(FakeDate.now()).toMatchInlineSnapshot(`1593993600000`);
});
it('should be instanceof Date', () => {
diff --git a/spec/frontend/helpers/mock_apollo_helper.js b/spec/frontend/helpers/mock_apollo_helper.js
index 8a5a160231c..914cce1d662 100644
--- a/spec/frontend/helpers/mock_apollo_helper.js
+++ b/spec/frontend/helpers/mock_apollo_helper.js
@@ -2,14 +2,14 @@ import { InMemoryCache } from 'apollo-cache-inmemory';
import { createMockClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo';
-export default (handlers = []) => {
+export default (handlers = [], resolvers = {}) => {
const fragmentMatcher = { match: () => true };
const cache = new InMemoryCache({
fragmentMatcher,
addTypename: false,
});
- const mockClient = createMockClient({ cache });
+ const mockClient = createMockClient({ cache, resolvers });
if (Array.isArray(handlers)) {
handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value));
diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js
index 1a88e80344e..2d560c43fa5 100644
--- a/spec/frontend/helpers/startup_css_helper_spec.js
+++ b/spec/frontend/helpers/startup_css_helper_spec.js
@@ -19,6 +19,26 @@ describe('waitForCSSLoaded', () => {
});
});
+ describe('when gon features is not provided', () => {
+ let originalGon;
+
+ beforeEach(() => {
+ originalGon = window.gon;
+ window.gon = null;
+ });
+
+ afterEach(() => {
+ window.gon = originalGon;
+ });
+
+ it('should invoke the action right away', async () => {
+ const events = waitForCSSLoaded(mockedCallback);
+ await events;
+
+ expect(mockedCallback).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('with startup css disabled', () => {
gon.features = {
startupCss: false,
diff --git a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js
deleted file mode 100644
index 42e0a20bc7b..00000000000
--- a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js
+++ /dev/null
@@ -1,75 +0,0 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
-import { createStore } from '~/ide/stores';
-import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue';
-import { file } from '../../helpers';
-import { removeWhitespace } from '../../../helpers/text_helper';
-
-describe('Multi-file editor commit sidebar list collapsed', () => {
- let vm;
- let store;
-
- beforeEach(() => {
- store = createStore();
-
- const Component = Vue.extend(listCollapsed);
-
- vm = createComponentWithStore(Component, store, {
- files: [
- {
- ...file('file1'),
- tempFile: true,
- },
- file('file2'),
- ],
- iconName: 'staged',
- title: 'Staged',
- });
-
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
- });
-
- it('renders added & modified files count', () => {
- expect(removeWhitespace(vm.$el.textContent).trim()).toBe('1 1');
- });
-
- describe('addedFilesLength', () => {
- it('returns an length of temp files', () => {
- expect(vm.addedFilesLength).toBe(1);
- });
- });
-
- describe('modifiedFilesLength', () => {
- it('returns an length of modified files', () => {
- expect(vm.modifiedFilesLength).toBe(1);
- });
- });
-
- describe('addedFilesIconClass', () => {
- it('includes multi-file-addition when addedFiles is not empty', () => {
- expect(vm.addedFilesIconClass).toContain('multi-file-addition');
- });
-
- it('excludes multi-file-addition when addedFiles is empty', () => {
- vm.files = [];
-
- expect(vm.addedFilesIconClass).not.toContain('multi-file-addition');
- });
- });
-
- describe('modifiedFilesClass', () => {
- it('includes multi-file-modified when addedFiles is not empty', () => {
- expect(vm.modifiedFilesClass).toContain('multi-file-modified');
- });
-
- it('excludes multi-file-modified when addedFiles is empty', () => {
- vm.files = [];
-
- expect(vm.modifiedFilesClass).not.toContain('multi-file-modified');
- });
- });
-});
diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js
index 2107ff96e95..636dfbf0b2a 100644
--- a/spec/frontend/ide/components/commit_sidebar/list_spec.js
+++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js
@@ -16,7 +16,6 @@ describe('Multi-file editor commit sidebar list', () => {
vm = createComponentWithStore(Component, store, {
title: 'Staged',
fileList: [],
- iconName: 'staged',
action: 'stageAllChanges',
actionBtnText: 'stage all',
actionBtnIcon: 'history',
diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js
index 86e4e8d8f89..72e9463945b 100644
--- a/spec/frontend/ide/components/ide_side_bar_spec.js
+++ b/spec/frontend/ide/components/ide_side_bar_spec.js
@@ -1,10 +1,12 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
import { createStore } from '~/ide/stores';
import IdeSidebar from '~/ide/components/ide_side_bar.vue';
import IdeTree from '~/ide/components/ide_tree.vue';
import RepoCommitSection from '~/ide/components/repo_commit_section.vue';
+import IdeReview from '~/ide/components/ide_review.vue';
import { leftSidebarViews } from '~/ide/constants';
import { projectData } from '../mock_data';
@@ -15,11 +17,12 @@ describe('IdeSidebar', () => {
let wrapper;
let store;
- function createComponent() {
+ function createComponent({ view = leftSidebarViews.edit.name } = {}) {
store = createStore();
store.state.currentProjectId = 'abcproject';
store.state.projects.abcproject = projectData;
+ store.state.currentActivityView = view;
return mount(IdeSidebar, {
store,
@@ -48,22 +51,46 @@ describe('IdeSidebar', () => {
expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(3);
});
- describe('activityBarComponent', () => {
- it('renders tree component', () => {
+ describe('deferred rendering components', () => {
+ it('fetches components on demand', async () => {
wrapper = createComponent();
expect(wrapper.find(IdeTree).exists()).toBe(true);
- });
+ expect(wrapper.find(IdeReview).exists()).toBe(false);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(false);
- it('renders commit component', async () => {
- wrapper = createComponent();
+ store.state.currentActivityView = leftSidebarViews.review.name;
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
- store.state.currentActivityView = leftSidebarViews.commit.name;
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(IdeReview).exists()).toBe(true);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(false);
+ store.state.currentActivityView = leftSidebarViews.commit.name;
+ await waitForPromises();
await wrapper.vm.$nextTick();
+ expect(wrapper.find(IdeTree).exists()).toBe(false);
+ expect(wrapper.find(IdeReview).exists()).toBe(false);
expect(wrapper.find(RepoCommitSection).exists()).toBe(true);
});
+ it.each`
+ view | tree | review | commit
+ ${leftSidebarViews.edit.name} | ${true} | ${false} | ${false}
+ ${leftSidebarViews.review.name} | ${false} | ${true} | ${false}
+ ${leftSidebarViews.commit.name} | ${false} | ${false} | ${true}
+ `('renders correct panels for $view', async ({ view, tree, review, commit } = {}) => {
+ wrapper = createComponent({
+ view,
+ });
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IdeTree).exists()).toBe(tree);
+ expect(wrapper.find(IdeReview).exists()).toBe(review);
+ expect(wrapper.find(RepoCommitSection).exists()).toBe(commit);
+ });
});
it('keeps the current activity view components alive', async () => {
@@ -72,7 +99,7 @@ describe('IdeSidebar', () => {
const ideTreeComponent = wrapper.find(IdeTree).element;
store.state.currentActivityView = leftSidebarViews.commit.name;
-
+ await waitForPromises();
await wrapper.vm.$nextTick();
expect(wrapper.find(IdeTree).exists()).toBe(false);
@@ -80,6 +107,7 @@ describe('IdeSidebar', () => {
store.state.currentActivityView = leftSidebarViews.edit.name;
+ await waitForPromises();
await wrapper.vm.$nextTick();
// reference to the elements remains the same, meaning the components were kept alive
diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js
index a7b07a9f0e2..ff3852b6775 100644
--- a/spec/frontend/ide/components/ide_spec.js
+++ b/spec/frontend/ide/components/ide_spec.js
@@ -1,127 +1,165 @@
-import Vue from 'vue';
-import { createComponentWithStore } from 'helpers/vue_mount_component_helper';
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { createStore } from '~/ide/stores';
+import ErrorMessage from '~/ide/components/error_message.vue';
+import FindFile from '~/vue_shared/components/file_finder/index.vue';
+import CommitEditorHeader from '~/ide/components/commit_sidebar/editor_header.vue';
+import RepoTabs from '~/ide/components/repo_tabs.vue';
+import IdeStatusBar from '~/ide/components/ide_status_bar.vue';
+import RightPane from '~/ide/components/panes/right.vue';
+import NewModal from '~/ide/components/new_dropdown/modal.vue';
+
import ide from '~/ide/components/ide.vue';
import { file } from '../helpers';
import { projectData } from '../mock_data';
-import extendStore from '~/ide/stores/extend';
-
-let store;
-function bootstrap(projData) {
- store = createStore();
+const localVue = createLocalVue();
+localVue.use(Vuex);
- extendStore(store, document.createElement('div'));
+describe('WebIDE', () => {
+ const emptyProjData = { ...projectData, empty_repo: true, branches: {} };
- const Component = Vue.extend(ide);
+ let wrapper;
- 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',
- });
-}
+ function createComponent({ projData = emptyProjData, state = {} } = {}) {
+ const store = createStore();
-describe('ide component, empty repo', () => {
- let vm;
+ store.state.currentProjectId = 'abcproject';
+ store.state.currentBranchId = 'master';
+ store.state.projects.abcproject = { ...projData };
+ store.state.trees['abcproject/master'] = {
+ tree: [],
+ loading: false,
+ };
+ Object.keys(state).forEach(key => {
+ store.state[key] = state[key];
+ });
- beforeEach(() => {
- const emptyProjData = { ...projectData, empty_repo: true, branches: {} };
- vm = bootstrap(emptyProjData);
- vm.$mount();
- });
+ return shallowMount(ide, {
+ store,
+ localVue,
+ stubs: {
+ ErrorMessage,
+ GlButton,
+ GlLoadingIcon,
+ CommitEditorHeader,
+ RepoTabs,
+ IdeStatusBar,
+ FindFile,
+ RightPane,
+ NewModal,
+ },
+ });
+ }
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
+ wrapper = null;
});
- 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, empty repo', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ projData: {
+ empty_repo: true,
+ },
+ });
});
- });
-});
-
-describe('ide component, non-empty repo', () => {
- let vm;
- beforeEach(() => {
- vm = bootstrap(projectData);
- vm.$mount();
- });
-
- afterEach(() => {
- vm.$destroy();
+ it('renders "New file" button in empty repo', async () => {
+ expect(wrapper.find('[title="New file"]').exists()).toBe(true);
+ });
});
- it('shows error message when set', done => {
- expect(vm.$el.querySelector('.gl-alert')).toBe(null);
+ describe('ide component, non-empty repo', () => {
+ describe('error message', () => {
+ it('does not show error message when it is not set', () => {
+ wrapper = createComponent({
+ state: {
+ errorMessage: null,
+ },
+ });
- vm.$store.state.errorMessage = {
- text: 'error',
- };
+ expect(wrapper.find(ErrorMessage).exists()).toBe(false);
+ });
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.gl-alert')).not.toBe(null);
+ it('shows error message when set', () => {
+ wrapper = createComponent({
+ state: {
+ errorMessage: {
+ text: 'error',
+ },
+ },
+ });
- done();
+ expect(wrapper.find(ErrorMessage).exists()).toBe(true);
+ });
});
- });
- describe('onBeforeUnload', () => {
- it('returns undefined when no staged files or changed files', () => {
- expect(vm.onBeforeUnload()).toBe(undefined);
- });
+ describe('onBeforeUnload', () => {
+ it('returns undefined when no staged files or changed files', () => {
+ wrapper = createComponent();
+ expect(wrapper.vm.onBeforeUnload()).toBe(undefined);
+ });
- it('returns warning text when their are changed files', () => {
- vm.$store.state.changedFiles.push(file());
+ it('returns warning text when their are changed files', () => {
+ wrapper = createComponent({
+ state: {
+ changedFiles: [file()],
+ },
+ });
- expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
- });
+ expect(wrapper.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());
+ it('returns warning text when their are staged files', () => {
+ wrapper = createComponent({
+ state: {
+ stagedFiles: [file()],
+ },
+ });
- expect(vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
- });
+ expect(wrapper.vm.onBeforeUnload()).toBe('Are you sure you want to lose unsaved changes?');
+ });
- it('updates event object', () => {
- const event = {};
- vm.$store.state.stagedFiles.push(file());
+ it('updates event object', () => {
+ const event = {};
+ wrapper = createComponent({
+ state: {
+ stagedFiles: [file()],
+ },
+ });
- vm.onBeforeUnload(event);
+ wrapper.vm.onBeforeUnload(event);
- expect(event.returnValue).toBe('Are you sure you want to lose unsaved changes?');
+ 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('non-existent branch', () => {
+ it('does not render "New file" button for non-existent branch when repo is not empty', () => {
+ wrapper = createComponent({
+ state: {
+ projects: {},
+ },
+ });
+
+ expect(wrapper.find('[title="New file"]').exists()).toBe(false);
});
});
- });
- describe('branch with files', () => {
- beforeEach(() => {
- store.state.trees['abcproject/master'].tree = [file()];
- });
+ describe('branch with files', () => {
+ beforeEach(() => {
+ wrapper = createComponent({
+ projData: {
+ empty_repo: false,
+ },
+ });
+ });
- it('does not render "New file" button', done => {
- vm.$nextTick(() => {
- expect(vm.$el.querySelector('.ide-empty-state button[title="New file"]')).toBeNull();
- done();
+ it('does not render "New file" button', () => {
+ expect(wrapper.find('[title="New file"]').exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js
index bb8165d1a52..02b5dc19bd8 100644
--- a/spec/frontend/ide/components/ide_status_list_spec.js
+++ b/spec/frontend/ide/components/ide_status_list_spec.js
@@ -6,17 +6,21 @@ import TerminalSyncStatusSafe from '~/ide/components/terminal_sync/terminal_sync
const TEST_FILE = {
name: 'lorem.md',
- editorRow: 3,
- editorColumn: 23,
- fileLanguage: 'markdown',
content: 'abc\nndef',
permalink: '/lorem.md',
};
+const TEST_FILE_EDITOR = {
+ fileLanguage: 'markdown',
+ editorRow: 3,
+ editorColumn: 23,
+};
+const TEST_EDITOR_POSITION = `${TEST_FILE_EDITOR.editorRow}:${TEST_FILE_EDITOR.editorColumn}`;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ide/components/ide_status_list', () => {
+ let activeFileEditor;
let activeFile;
let store;
let wrapper;
@@ -27,6 +31,14 @@ describe('ide/components/ide_status_list', () => {
getters: {
activeFile: () => activeFile,
},
+ modules: {
+ editor: {
+ namespaced: true,
+ getters: {
+ activeFileEditor: () => activeFileEditor,
+ },
+ },
+ },
});
wrapper = shallowMount(IdeStatusList, {
@@ -38,6 +50,7 @@ describe('ide/components/ide_status_list', () => {
beforeEach(() => {
activeFile = TEST_FILE;
+ activeFileEditor = TEST_FILE_EDITOR;
});
afterEach(() => {
@@ -47,8 +60,6 @@ describe('ide/components/ide_status_list', () => {
wrapper = null;
});
- const getEditorPosition = file => `${file.editorRow}:${file.editorColumn}`;
-
describe('with regular file', () => {
beforeEach(() => {
createComponent();
@@ -65,11 +76,11 @@ describe('ide/components/ide_status_list', () => {
});
it('shows file editor position', () => {
- expect(wrapper.text()).toContain(getEditorPosition(TEST_FILE));
+ expect(wrapper.text()).toContain(TEST_EDITOR_POSITION);
});
it('shows file language', () => {
- expect(wrapper.text()).toContain(TEST_FILE.fileLanguage);
+ expect(wrapper.text()).toContain(TEST_FILE_EDITOR.fileLanguage);
});
});
@@ -81,7 +92,7 @@ describe('ide/components/ide_status_list', () => {
});
it('does not show file editor position', () => {
- expect(wrapper.text()).not.toContain(getEditorPosition(TEST_FILE));
+ expect(wrapper.text()).not.toContain(TEST_EDITOR_POSITION);
});
});
diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js
index 7f083fa7c25..c1744fefe20 100644
--- a/spec/frontend/ide/components/pipelines/list_spec.js
+++ b/spec/frontend/ide/components/pipelines/list_spec.js
@@ -1,11 +1,10 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlTab } from '@gitlab/ui';
import { TEST_HOST } from 'helpers/test_constants';
import { pipelines } from 'jest/ide/mock_data';
import List from '~/ide/components/pipelines/list.vue';
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 IDEServices from '~/ide/services';
@@ -167,7 +166,7 @@ describe('IDE pipelines list', () => {
createComponent({}, { ...withLatestPipelineState, stages, isLoadingJobs });
const jobProps = wrapper
- .findAll(Tab)
+ .findAll(GlTab)
.at(0)
.find(JobsList)
.props();
@@ -182,7 +181,7 @@ describe('IDE pipelines list', () => {
createComponent({}, { ...withLatestPipelineState, isLoadingJobs });
const jobProps = wrapper
- .findAll(Tab)
+ .findAll(GlTab)
.at(1)
.find(JobsList)
.props();
diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js
index 9f4c9c1622a..71a4f08cfb4 100644
--- a/spec/frontend/ide/components/repo_editor_spec.js
+++ b/spec/frontend/ide/components/repo_editor_spec.js
@@ -55,7 +55,6 @@ describe('RepoEditor', () => {
beforeEach(() => {
const f = {
...file('file.txt'),
- viewMode: FILE_VIEW_MODE_EDITOR,
content: 'hello world',
};
@@ -92,6 +91,8 @@ describe('RepoEditor', () => {
});
const findEditor = () => vm.$el.querySelector('.multi-file-editor-holder');
+ const changeViewMode = viewMode =>
+ store.dispatch('editor/updateFileEditor', { path: vm.file.path, data: { viewMode } });
describe('default', () => {
beforeEach(() => {
@@ -409,7 +410,7 @@ describe('RepoEditor', () => {
describe('when files view mode is preview', () => {
beforeEach(done => {
jest.spyOn(vm.editor, 'updateDimensions').mockImplementation();
- vm.file.viewMode = FILE_VIEW_MODE_PREVIEW;
+ changeViewMode(FILE_VIEW_MODE_PREVIEW);
vm.file.name = 'myfile.md';
vm.file.content = 'hello world';
@@ -423,7 +424,7 @@ describe('RepoEditor', () => {
describe('when file view mode changes to editor', () => {
it('should update dimensions', () => {
- vm.file.viewMode = FILE_VIEW_MODE_EDITOR;
+ changeViewMode(FILE_VIEW_MODE_EDITOR);
return vm.$nextTick().then(() => {
expect(vm.editor.updateDimensions).toHaveBeenCalled();
diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js
index f35726de27c..a44c8b4d5ee 100644
--- a/spec/frontend/ide/components/repo_tab_spec.js
+++ b/spec/frontend/ide/components/repo_tab_spec.js
@@ -100,6 +100,18 @@ describe('RepoTab', () => {
expect(wrapper.find('.file-modified').exists()).toBe(true);
});
+ it.each`
+ tabProps | closeLabel
+ ${{}} | ${'Close foo.txt'}
+ ${{ changed: true }} | ${'foo.txt changed'}
+ `('close button has label ($closeLabel) with tab ($tabProps)', ({ tabProps, closeLabel }) => {
+ const tab = { ...file('foo.txt'), ...tabProps };
+
+ createComponent({ tab });
+
+ expect(wrapper.find('button').attributes('aria-label')).toBe(closeLabel);
+ });
+
describe('locked file', () => {
let f;
@@ -122,9 +134,7 @@ describe('RepoTab', () => {
});
it('renders a tooltip', () => {
- expect(wrapper.find('span:nth-child(2)').attributes('data-original-title')).toContain(
- 'Locked by testuser',
- );
+ expect(wrapper.find('span:nth-child(2)').attributes('title')).toBe('Locked by testuser');
});
});
diff --git a/spec/frontend/ide/components/terminal/empty_state_spec.js b/spec/frontend/ide/components/terminal/empty_state_spec.js
index a3f2089608d..b62470f67b6 100644
--- a/spec/frontend/ide/components/terminal/empty_state_spec.js
+++ b/spec/frontend/ide/components/terminal/empty_state_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import TerminalEmptyState from '~/ide/components/terminal/empty_state.vue';
@@ -36,7 +36,7 @@ describe('IDE TerminalEmptyState', () => {
const img = wrapper.find('.svg-content img');
expect(img.exists()).toBe(true);
- expect(img.attributes('src')).toEqual(TEST_PATH);
+ expect(img.attributes('src')).toBe(TEST_PATH);
});
it('when loading, shows loading icon', () => {
@@ -71,24 +71,23 @@ describe('IDE TerminalEmptyState', () => {
},
});
- button = wrapper.find('button');
+ button = wrapper.find(GlButton);
});
it('shows button', () => {
- expect(button.text()).toEqual('Start Web Terminal');
- expect(button.attributes('disabled')).toBeFalsy();
+ expect(button.text()).toBe('Start Web Terminal');
+ expect(button.props('disabled')).toBe(false);
});
it('emits start when button is clicked', () => {
- expect(wrapper.emitted().start).toBeFalsy();
-
- button.trigger('click');
+ expect(wrapper.emitted().start).toBeUndefined();
+ button.vm.$emit('click');
expect(wrapper.emitted().start).toHaveLength(1);
});
it('shows help path link', () => {
- expect(wrapper.find('a').attributes('href')).toEqual(TEST_HELP_PATH);
+ expect(wrapper.find('a').attributes('href')).toBe(TEST_HELP_PATH);
});
});
@@ -101,7 +100,7 @@ describe('IDE TerminalEmptyState', () => {
},
});
- expect(wrapper.find('button').attributes('disabled')).not.toBe(null);
- expect(wrapper.find('.bs-callout').element.innerHTML).toEqual(TEST_HTML_MESSAGE);
+ expect(wrapper.find(GlButton).props('disabled')).toBe(true);
+ expect(wrapper.find(GlAlert).html()).toContain(TEST_HTML_MESSAGE);
});
});
diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js
index 0e85b523cbd..6b65dd96ef4 100644
--- a/spec/frontend/ide/helpers.js
+++ b/spec/frontend/ide/helpers.js
@@ -1,5 +1,6 @@
import * as pathUtils from 'path';
import { decorateData } from '~/ide/stores/utils';
+import { commitActionTypes } from '~/ide/constants';
export const file = (name = 'name', id = name, type = '', parent = null) =>
decorateData({
@@ -28,3 +29,17 @@ export const createEntriesFromPaths = paths =>
...entries,
};
}, {});
+
+export const createTriggerChangeAction = payload => ({
+ type: 'triggerFilesChange',
+ ...(payload ? { payload } : {}),
+});
+
+export const createTriggerRenamePayload = (path, newPath) => ({
+ type: commitActionTypes.move,
+ path,
+ newPath,
+});
+
+export const createTriggerRenameAction = (path, newPath) =>
+ createTriggerChangeAction(createTriggerRenamePayload(path, newPath));
diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js
index 8f7fcc25cf0..cc290fc526e 100644
--- a/spec/frontend/ide/stores/actions/file_spec.js
+++ b/spec/frontend/ide/stores/actions/file_spec.js
@@ -7,7 +7,7 @@ import * as types from '~/ide/stores/mutation_types';
import service from '~/ide/services';
import { createRouter } from '~/ide/ide_router';
import eventHub from '~/ide/eventhub';
-import { file } from '../../helpers';
+import { file, createTriggerRenameAction } from '../../helpers';
const ORIGINAL_CONTENT = 'original content';
const RELATIVE_URL_ROOT = '/gitlab';
@@ -785,13 +785,19 @@ describe('IDE store file actions', () => {
});
describe('triggerFilesChange', () => {
+ const { payload: renamePayload } = createTriggerRenameAction('test', '123');
+
beforeEach(() => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
});
- it('emits event that files have changed', () => {
- return store.dispatch('triggerFilesChange').then(() => {
- expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change');
+ it.each`
+ args | payload
+ ${[]} | ${{}}
+ ${[renamePayload]} | ${renamePayload}
+ `('emits event that files have changed (args=$args)', ({ args, payload }) => {
+ return store.dispatch('triggerFilesChange', ...args).then(() => {
+ expect(eventHub.$emit).toHaveBeenCalledWith('ide.files.change', payload);
});
});
});
diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js
index ebf39df2f6f..04128c27e70 100644
--- a/spec/frontend/ide/stores/actions_spec.js
+++ b/spec/frontend/ide/stores/actions_spec.js
@@ -19,7 +19,7 @@ import {
} from '~/ide/stores/actions';
import axios from '~/lib/utils/axios_utils';
import * as types from '~/ide/stores/mutation_types';
-import { file } from '../helpers';
+import { file, createTriggerRenameAction, createTriggerChangeAction } from '../helpers';
import testAction from '../../helpers/vuex_action_helper';
import eventHub from '~/ide/eventhub';
@@ -522,7 +522,7 @@ describe('Multi-file store actions', () => {
'path',
store.state,
[{ type: types.DELETE_ENTRY, payload: 'path' }],
- [{ type: 'stageChange', payload: 'path' }, { type: 'triggerFilesChange' }],
+ [{ type: 'stageChange', payload: 'path' }, createTriggerChangeAction()],
done,
);
});
@@ -551,7 +551,7 @@ describe('Multi-file store actions', () => {
[{ type: types.DELETE_ENTRY, payload: 'testFolder/entry-to-delete' }],
[
{ type: 'stageChange', payload: 'testFolder/entry-to-delete' },
- { type: 'triggerFilesChange' },
+ createTriggerChangeAction(),
],
done,
);
@@ -614,7 +614,7 @@ describe('Multi-file store actions', () => {
testEntry.path,
store.state,
[{ type: types.DELETE_ENTRY, payload: testEntry.path }],
- [{ type: 'stageChange', payload: testEntry.path }, { type: 'triggerFilesChange' }],
+ [{ type: 'stageChange', payload: testEntry.path }, createTriggerChangeAction()],
done,
);
});
@@ -754,7 +754,7 @@ describe('Multi-file store actions', () => {
payload: origEntry,
},
],
- [{ type: 'triggerFilesChange' }],
+ [createTriggerRenameAction('renamed', 'orig')],
done,
);
});
@@ -767,7 +767,7 @@ describe('Multi-file store actions', () => {
{ path: 'orig', name: 'renamed' },
store.state,
[expect.objectContaining({ type: types.RENAME_ENTRY })],
- [{ type: 'triggerFilesChange' }],
+ [createTriggerRenameAction('orig', 'renamed')],
done,
);
});
diff --git a/spec/frontend/ide/stores/modules/editor/actions_spec.js b/spec/frontend/ide/stores/modules/editor/actions_spec.js
new file mode 100644
index 00000000000..6a420ac32de
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/editor/actions_spec.js
@@ -0,0 +1,36 @@
+import testAction from 'helpers/vuex_action_helper';
+import * as types from '~/ide/stores/modules/editor/mutation_types';
+import * as actions from '~/ide/stores/modules/editor/actions';
+import { createTriggerRenamePayload } from '../../../helpers';
+
+describe('~/ide/stores/modules/editor/actions', () => {
+ describe('updateFileEditor', () => {
+ it('commits with payload', () => {
+ const payload = {};
+
+ testAction(actions.updateFileEditor, payload, {}, [
+ { type: types.UPDATE_FILE_EDITOR, payload },
+ ]);
+ });
+ });
+
+ describe('removeFileEditor', () => {
+ it('commits with payload', () => {
+ const payload = 'path/to/file.txt';
+
+ testAction(actions.removeFileEditor, payload, {}, [
+ { type: types.REMOVE_FILE_EDITOR, payload },
+ ]);
+ });
+ });
+
+ describe('renameFileEditor', () => {
+ it('commits with payload', () => {
+ const payload = createTriggerRenamePayload('test', 'test123');
+
+ testAction(actions.renameFileEditor, payload, {}, [
+ { type: types.RENAME_FILE_EDITOR, payload },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/editor/getters_spec.js b/spec/frontend/ide/stores/modules/editor/getters_spec.js
new file mode 100644
index 00000000000..55e1e31f66f
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/editor/getters_spec.js
@@ -0,0 +1,31 @@
+import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils';
+import * as getters from '~/ide/stores/modules/editor/getters';
+
+const TEST_PATH = 'test/path.md';
+const TEST_FILE_EDITOR = {
+ ...createDefaultFileEditor(),
+ editorRow: 7,
+ editorColumn: 8,
+ fileLanguage: 'markdown',
+};
+
+describe('~/ide/stores/modules/editor/getters', () => {
+ describe('activeFileEditor', () => {
+ it.each`
+ activeFile | fileEditors | expected
+ ${null} | ${{}} | ${null}
+ ${{}} | ${{}} | ${createDefaultFileEditor()}
+ ${{ path: TEST_PATH }} | ${{}} | ${createDefaultFileEditor()}
+ ${{ path: TEST_PATH }} | ${{ bogus: createDefaultFileEditor(), [TEST_PATH]: TEST_FILE_EDITOR }} | ${TEST_FILE_EDITOR}
+ `(
+ 'with activeFile=$activeFile and fileEditors=$fileEditors',
+ ({ activeFile, fileEditors, expected }) => {
+ const rootGetters = { activeFile };
+ const state = { fileEditors };
+ const result = getters.activeFileEditor(state, {}, {}, rootGetters);
+
+ expect(result).toEqual(expected);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/editor/mutations_spec.js b/spec/frontend/ide/stores/modules/editor/mutations_spec.js
new file mode 100644
index 00000000000..e4b330b3174
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/editor/mutations_spec.js
@@ -0,0 +1,78 @@
+import { createDefaultFileEditor } from '~/ide/stores/modules/editor/utils';
+import * as types from '~/ide/stores/modules/editor/mutation_types';
+import mutations from '~/ide/stores/modules/editor/mutations';
+import { createTriggerRenamePayload } from '../../../helpers';
+
+const TEST_PATH = 'test/path.md';
+
+describe('~/ide/stores/modules/editor/mutations', () => {
+ describe(types.UPDATE_FILE_EDITOR, () => {
+ it('with path that does not exist, should initialize with default values', () => {
+ const state = { fileEditors: {} };
+ const data = { fileLanguage: 'markdown' };
+
+ mutations[types.UPDATE_FILE_EDITOR](state, { path: TEST_PATH, data });
+
+ expect(state.fileEditors).toEqual({
+ [TEST_PATH]: {
+ ...createDefaultFileEditor(),
+ ...data,
+ },
+ });
+ });
+
+ it('with existing path, should overwrite values', () => {
+ const state = {
+ fileEditors: {
+ foo: {},
+ [TEST_PATH]: { ...createDefaultFileEditor(), editorRow: 7, editorColumn: 7 },
+ },
+ };
+
+ mutations[types.UPDATE_FILE_EDITOR](state, {
+ path: TEST_PATH,
+ data: { fileLanguage: 'markdown' },
+ });
+
+ expect(state).toEqual({
+ fileEditors: {
+ foo: {},
+ [TEST_PATH]: {
+ ...createDefaultFileEditor(),
+ editorRow: 7,
+ editorColumn: 7,
+ fileLanguage: 'markdown',
+ },
+ },
+ });
+ });
+ });
+
+ describe(types.REMOVE_FILE_EDITOR, () => {
+ it.each`
+ fileEditors | path | expected
+ ${{}} | ${'does/not/exist.txt'} | ${{}}
+ ${{ foo: {}, [TEST_PATH]: {} }} | ${TEST_PATH} | ${{ foo: {} }}
+ `('removes file $path', ({ fileEditors, path, expected }) => {
+ const state = { fileEditors };
+
+ mutations[types.REMOVE_FILE_EDITOR](state, path);
+
+ expect(state).toEqual({ fileEditors: expected });
+ });
+ });
+
+ describe(types.RENAME_FILE_EDITOR, () => {
+ it.each`
+ fileEditors | payload | expected
+ ${{ foo: {} }} | ${createTriggerRenamePayload('does/not/exist', 'abc')} | ${{ foo: {} }}
+ ${{ foo: { a: 1 }, bar: {} }} | ${createTriggerRenamePayload('foo', 'abc/def')} | ${{ 'abc/def': { a: 1 }, bar: {} }}
+ `('renames fileEditor at $payload', ({ fileEditors, payload, expected }) => {
+ const state = { fileEditors };
+
+ mutations[types.RENAME_FILE_EDITOR](state, payload);
+
+ expect(state).toEqual({ fileEditors: expected });
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/modules/editor/setup_spec.js b/spec/frontend/ide/stores/modules/editor/setup_spec.js
new file mode 100644
index 00000000000..71b5d7590c5
--- /dev/null
+++ b/spec/frontend/ide/stores/modules/editor/setup_spec.js
@@ -0,0 +1,44 @@
+import Vuex from 'vuex';
+import eventHub from '~/ide/eventhub';
+import { createStoreOptions } from '~/ide/stores';
+import { setupFileEditorsSync } from '~/ide/stores/modules/editor/setup';
+import { createTriggerRenamePayload } from '../../../helpers';
+
+describe('~/ide/stores/modules/editor/setup', () => {
+ let store;
+
+ beforeEach(() => {
+ store = new Vuex.Store(createStoreOptions());
+ store.state.entries = {
+ foo: {},
+ bar: {},
+ };
+ store.state.editor.fileEditors = {
+ foo: {},
+ bizz: {},
+ };
+
+ setupFileEditorsSync(store);
+ });
+
+ it('when files change is emitted, removes unused fileEditors', () => {
+ eventHub.$emit('ide.files.change');
+
+ expect(store.state.entries).toEqual({
+ foo: {},
+ bar: {},
+ });
+ expect(store.state.editor.fileEditors).toEqual({
+ foo: {},
+ });
+ });
+
+ it('when files rename is emitted, renames fileEditor', () => {
+ eventHub.$emit('ide.files.change', createTriggerRenamePayload('foo', 'foo_new'));
+
+ expect(store.state.editor.fileEditors).toEqual({
+ foo_new: {},
+ bizz: {},
+ });
+ });
+});
diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js
index d303de6e9ef..fd39cf21635 100644
--- a/spec/frontend/ide/stores/mutations/file_spec.js
+++ b/spec/frontend/ide/stores/mutations/file_spec.js
@@ -1,6 +1,5 @@
import mutations from '~/ide/stores/mutations/file';
import { createStore } from '~/ide/stores';
-import { FILE_VIEW_MODE_PREVIEW } from '~/ide/constants';
import { file } from '../../helpers';
describe('IDE store file mutations', () => {
@@ -532,17 +531,6 @@ describe('IDE store file mutations', () => {
});
});
- describe('SET_FILE_VIEWMODE', () => {
- it('updates file view mode', () => {
- mutations.SET_FILE_VIEWMODE(localState, {
- file: localFile,
- viewMode: FILE_VIEW_MODE_PREVIEW,
- });
-
- expect(localFile.viewMode).toBe(FILE_VIEW_MODE_PREVIEW);
- });
- });
-
describe('ADD_PENDING_TAB', () => {
beforeEach(() => {
const f = { ...file('openFile'), path: 'openFile', active: true, opened: true };
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 1dbad588ec4..7322c7c1ae1 100644
--- a/spec/frontend/import_projects/components/import_projects_table_spec.js
+++ b/spec/frontend/import_projects/components/import_projects_table_spec.js
@@ -33,7 +33,6 @@ describe('ImportProjectsTable', () => {
const importAllFn = jest.fn();
const importAllModalShowFn = jest.fn();
- const setPageFn = jest.fn();
const fetchReposFn = jest.fn();
function createComponent({
@@ -60,7 +59,6 @@ describe('ImportProjectsTable', () => {
stopJobsPolling: jest.fn(),
clearJobsEtagPoll: jest.fn(),
setFilter: jest.fn(),
- setPage: setPageFn,
},
});
diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js
index 6951f2bf04d..06afb20c6a2 100644
--- a/spec/frontend/import_projects/store/actions_spec.js
+++ b/spec/frontend/import_projects/store/actions_spec.js
@@ -16,6 +16,7 @@ import {
RECEIVE_NAMESPACES_SUCCESS,
RECEIVE_NAMESPACES_ERROR,
SET_PAGE,
+ SET_FILTER,
} from '~/import_projects/store/mutation_types';
import actionsFactory from '~/import_projects/store/actions';
import { getImportTarget } from '~/import_projects/store/getters';
@@ -40,7 +41,7 @@ const {
fetchImport,
fetchJobs,
fetchNamespaces,
- setPage,
+ setFilter,
} = actionsFactory({
endpoints,
});
@@ -68,6 +69,7 @@ describe('import_projects store actions', () => {
importStatus: STATUSES.NONE,
},
],
+ provider: 'provider',
};
localState.getImportTarget = getImportTarget(localState);
@@ -149,7 +151,28 @@ describe('import_projects store actions', () => {
);
});
- describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => {
+ describe('when rate limited', () => {
+ it('commits RECEIVE_REPOS_ERROR and shows rate limited error message', async () => {
+ mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(429);
+
+ await testAction(
+ fetchRepos,
+ null,
+ { ...localState, filter: 'filter' },
+ [
+ { type: SET_PAGE, payload: 1 },
+ { type: REQUEST_REPOS },
+ { type: SET_PAGE, payload: 0 },
+ { type: RECEIVE_REPOS_ERROR },
+ ],
+ [],
+ );
+
+ expect(createFlash).toHaveBeenCalledWith('Provider rate limit exceeded. Try again later');
+ });
+ });
+
+ describe('when filtered', () => {
it('fetches repos with filter applied', () => {
mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload);
@@ -359,21 +382,17 @@ describe('import_projects store actions', () => {
],
);
});
+ });
- describe('setPage', () => {
- it('dispatches fetchRepos and commits setPage when page number differs from current one', async () => {
- await testAction(
- setPage,
- 2,
- { ...localState, pageInfo: { page: 1 } },
- [{ type: SET_PAGE, payload: 2 }],
- [{ type: 'fetchRepos' }],
- );
- });
-
- it('does not perform any action if page equals to current one', async () => {
- await testAction(setPage, 2, { ...localState, pageInfo: { page: 2 } }, [], []);
- });
+ describe('setFilter', () => {
+ it('dispatches sets the filter value and dispatches fetchRepos', async () => {
+ await testAction(
+ setFilter,
+ 'filteredRepo',
+ localState,
+ [{ type: SET_FILTER, payload: 'filteredRepo' }],
+ [{ type: 'fetchRepos' }],
+ );
});
});
});
diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js
index 709f66bb352..6329a84ff6e 100644
--- a/spec/frontend/incidents/components/incidents_list_spec.js
+++ b/spec/frontend/incidents/components/incidents_list_spec.js
@@ -10,6 +10,7 @@ import {
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
+ TH_INCIDENT_SLA_TEST_ID,
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
} from '~/incidents/constants';
@@ -277,10 +278,11 @@ describe('Incidents List', () => {
const noneSort = 'none';
it.each`
- selector | initialSort | firstSort | nextSort
- ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
- ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
- ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ selector | initialSort | firstSort | nextSort
+ ${TH_CREATED_AT_TEST_ID} | ${descSort} | ${ascSort} | ${descSort}
+ ${TH_SEVERITY_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${TH_PUBLISHED_TEST_ID} | ${noneSort} | ${descSort} | ${ascSort}
+ ${TH_INCIDENT_SLA_TEST_ID} | ${noneSort} | ${ascSort} | ${descSort}
`('updates sort with new direction', async ({ selector, initialSort, firstSort, nextSort }) => {
const [[attr, value]] = Object.entries(selector);
const columnHeader = () => wrapper.find(`[${attr}="${value}"]`);
diff --git a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
index 02f311f579f..b570ab4e844 100644
--- a/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
+++ b/spec/frontend/integrations/edit/components/confirmation_modal_spec.js
@@ -34,7 +34,7 @@ describe('ConfirmationModal', () => {
'Saving will update the default settings for all projects that are not using custom settings.',
);
expect(findGlModal().text()).toContain(
- 'Projects using custom settings will not be impacted unless the project owner chooses to use instance-level defaults.',
+ 'Projects using custom settings will not be impacted unless the project owner chooses to use parent level defaults.',
);
});
diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js
index efcc727277a..97e77ac87ab 100644
--- a/spec/frontend/integrations/edit/components/integration_form_spec.js
+++ b/spec/frontend/integrations/edit/components/integration_form_spec.js
@@ -5,10 +5,12 @@ import IntegrationForm from '~/integrations/edit/components/integration_form.vue
import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue';
import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue';
import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue';
+import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue';
import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue';
import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue';
import TriggerFields from '~/integrations/edit/components/trigger_fields.vue';
import DynamicField from '~/integrations/edit/components/dynamic_field.vue';
+import { integrationLevels } from '~/integrations/edit/constants';
describe('IntegrationForm', () => {
let wrapper;
@@ -43,6 +45,8 @@ describe('IntegrationForm', () => {
const findOverrideDropdown = () => wrapper.find(OverrideDropdown);
const findActiveCheckbox = () => wrapper.find(ActiveCheckbox);
const findConfirmationModal = () => wrapper.find(ConfirmationModal);
+ const findResetConfirmationModal = () => wrapper.find(ResetConfirmationModal);
+ const findResetButton = () => wrapper.find('[data-testid="reset-button"]');
const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields);
const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields);
const findTriggerFields = () => wrapper.find(TriggerFields);
@@ -69,14 +73,70 @@ describe('IntegrationForm', () => {
describe('integrationLevel is instance', () => {
it('renders ConfirmationModal', () => {
createComponent({
- integrationLevel: 'instance',
+ integrationLevel: integrationLevels.INSTANCE,
});
expect(findConfirmationModal().exists()).toBe(true);
});
+
+ describe('resetPath is empty', () => {
+ it('does not render ResetConfirmationModal and button', () => {
+ createComponent({
+ integrationLevel: integrationLevels.INSTANCE,
+ });
+
+ expect(findResetButton().exists()).toBe(false);
+ expect(findResetConfirmationModal().exists()).toBe(false);
+ });
+ });
+
+ describe('resetPath is present', () => {
+ it('renders ResetConfirmationModal and button', () => {
+ createComponent({
+ integrationLevel: integrationLevels.INSTANCE,
+ resetPath: 'resetPath',
+ });
+
+ expect(findResetButton().exists()).toBe(true);
+ expect(findResetConfirmationModal().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('integrationLevel is group', () => {
+ it('renders ConfirmationModal', () => {
+ createComponent({
+ integrationLevel: integrationLevels.GROUP,
+ });
+
+ expect(findConfirmationModal().exists()).toBe(true);
+ });
+
+ describe('resetPath is empty', () => {
+ it('does not render ResetConfirmationModal and button', () => {
+ createComponent({
+ integrationLevel: integrationLevels.GROUP,
+ });
+
+ expect(findResetButton().exists()).toBe(false);
+ expect(findResetConfirmationModal().exists()).toBe(false);
+ });
+ });
+
+ describe('resetPath is present', () => {
+ it('renders ResetConfirmationModal and button', () => {
+ createComponent({
+ integrationLevel: integrationLevels.GROUP,
+ resetPath: 'resetPath',
+ });
+
+ expect(findResetButton().exists()).toBe(true);
+ expect(findResetConfirmationModal().exists()).toBe(true);
+ });
+ });
});
- describe('integrationLevel is not instance', () => {
+ describe('integrationLevel is project', () => {
it('does not render ConfirmationModal', () => {
createComponent({
integrationLevel: 'project',
@@ -84,6 +144,16 @@ describe('IntegrationForm', () => {
expect(findConfirmationModal().exists()).toBe(false);
});
+
+ it('does not render ResetConfirmationModal and button', () => {
+ createComponent({
+ integrationLevel: 'project',
+ resetPath: 'resetPath',
+ });
+
+ expect(findResetButton().exists()).toBe(false);
+ expect(findResetConfirmationModal().exists()).toBe(false);
+ });
});
describe('type is "slack"', () => {
diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js
index 5356c0a411b..5b5c8d6f76e 100644
--- a/spec/frontend/integrations/edit/store/actions_spec.js
+++ b/spec/frontend/integrations/edit/store/actions_spec.js
@@ -1,6 +1,11 @@
import testAction from 'helpers/vuex_action_helper';
import createState from '~/integrations/edit/store/state';
-import { setOverride } from '~/integrations/edit/store/actions';
+import {
+ setOverride,
+ setIsSaving,
+ setIsTesting,
+ setIsResetting,
+} from '~/integrations/edit/store/actions';
import * as types from '~/integrations/edit/store/mutation_types';
describe('Integration form store actions', () => {
@@ -15,4 +20,24 @@ describe('Integration form store actions', () => {
return testAction(setOverride, true, state, [{ type: types.SET_OVERRIDE, payload: true }]);
});
});
+
+ describe('setIsSaving', () => {
+ it('should commit isSaving mutation', () => {
+ return testAction(setIsSaving, true, state, [{ type: types.SET_IS_SAVING, payload: true }]);
+ });
+ });
+
+ describe('setIsTesting', () => {
+ it('should commit isTesting mutation', () => {
+ return testAction(setIsTesting, true, state, [{ type: types.SET_IS_TESTING, payload: true }]);
+ });
+ });
+
+ describe('setIsResetting', () => {
+ it('should commit isResetting mutation', () => {
+ return testAction(setIsResetting, true, state, [
+ { type: types.SET_IS_RESETTING, payload: true },
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js
index 3353e0c84cc..7d4532a1059 100644
--- a/spec/frontend/integrations/edit/store/getters_spec.js
+++ b/spec/frontend/integrations/edit/store/getters_spec.js
@@ -1,5 +1,12 @@
-import { currentKey, isInheriting, propsSource } from '~/integrations/edit/store/getters';
+import {
+ currentKey,
+ isInheriting,
+ isDisabled,
+ propsSource,
+} from '~/integrations/edit/store/getters';
import createState from '~/integrations/edit/store/state';
+import mutations from '~/integrations/edit/store/mutations';
+import * as types from '~/integrations/edit/store/mutation_types';
import { mockIntegrationProps } from '../mock_data';
describe('Integration form store getters', () => {
@@ -45,6 +52,29 @@ describe('Integration form store getters', () => {
});
});
+ describe('isDisabled', () => {
+ it.each`
+ isSaving | isTesting | isResetting | expected
+ ${false} | ${false} | ${false} | ${false}
+ ${true} | ${false} | ${false} | ${true}
+ ${false} | ${true} | ${false} | ${true}
+ ${false} | ${false} | ${true} | ${true}
+ ${false} | ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true} | ${true}
+ ${true} | ${true} | ${false} | ${true}
+ ${true} | ${true} | ${true} | ${true}
+ `(
+ 'when isSaving = $isSaving, isTesting = $isTesting, isResetting = $isResetting then isDisabled = $expected',
+ ({ isSaving, isTesting, isResetting, expected }) => {
+ mutations[types.SET_IS_SAVING](state, isSaving);
+ mutations[types.SET_IS_TESTING](state, isTesting);
+ mutations[types.SET_IS_RESETTING](state, isResetting);
+
+ expect(isDisabled(state)).toBe(expected);
+ },
+ );
+ });
+
describe('propsSource', () => {
beforeEach(() => {
state.defaultState = defaultState;
diff --git a/spec/frontend/integrations/edit/store/mutations_spec.js b/spec/frontend/integrations/edit/store/mutations_spec.js
index 4b733726d44..4707b4b3714 100644
--- a/spec/frontend/integrations/edit/store/mutations_spec.js
+++ b/spec/frontend/integrations/edit/store/mutations_spec.js
@@ -16,4 +16,28 @@ describe('Integration form store mutations', () => {
expect(state.override).toBe(true);
});
});
+
+ describe(`${types.SET_IS_SAVING}`, () => {
+ it('sets isSaving', () => {
+ mutations[types.SET_IS_SAVING](state, true);
+
+ expect(state.isSaving).toBe(true);
+ });
+ });
+
+ describe(`${types.SET_IS_TESTING}`, () => {
+ it('sets isTesting', () => {
+ mutations[types.SET_IS_TESTING](state, true);
+
+ expect(state.isTesting).toBe(true);
+ });
+ });
+
+ describe(`${types.SET_IS_RESETTING}`, () => {
+ it('sets isResetting', () => {
+ mutations[types.SET_IS_RESETTING](state, true);
+
+ expect(state.isResetting).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js
index fc193850a94..4d0f4a1da71 100644
--- a/spec/frontend/integrations/edit/store/state_spec.js
+++ b/spec/frontend/integrations/edit/store/state_spec.js
@@ -7,6 +7,7 @@ describe('Integration form state factory', () => {
customState: {},
isSaving: false,
isTesting: false,
+ isResetting: false,
override: false,
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 0be0fbbde2d..4ac2a28105c 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -3,24 +3,31 @@ import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink } from '@gi
import Api from '~/api';
import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue';
-const groupId = '1';
-const groupName = 'testgroup';
+const id = '1';
+const name = 'testgroup';
+const isProject = false;
const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 };
const defaultAccessLevel = '10';
const helpLink = 'https://example.com';
-const createComponent = () => {
+const createComponent = (data = {}) => {
return shallowMount(InviteMembersModal, {
propsData: {
- groupId,
- groupName,
+ id,
+ name,
+ isProject,
accessLevels,
defaultAccessLevel,
helpLink,
},
+ data() {
+ return data;
+ },
stubs: {
- GlSprintf,
'gl-modal': '<div><slot name="modal-footer"></slot><slot></slot></div>',
+ 'gl-dropdown': true,
+ 'gl-dropdown-item': true,
+ GlSprintf,
},
});
};
@@ -34,7 +41,7 @@ describe('InviteMembersModal', () => {
});
const findDropdown = () => wrapper.find(GlDropdown);
- const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDropdownItems = () => findDropdown().findAll(GlDropdownItem);
const findDatepicker = () => wrapper.find(GlDatepicker);
const findLink = () => wrapper.find(GlLink);
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
@@ -88,25 +95,69 @@ describe('InviteMembersModal', () => {
format: 'json',
};
- beforeEach(() => {
- wrapper = createComponent();
+ describe('when the invite was sent successfully', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
- jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData });
- wrapper.vm.$toast = { show: jest.fn() };
+ wrapper.vm.submitForm(postData);
+ });
- wrapper.vm.submitForm(postData);
+ it('displays the successful toastMessage', () => {
+ const toastMessageSuccessful = 'Members were successfully added';
+
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
+ toastMessageSuccessful,
+ wrapper.vm.toastOptions,
+ );
+ });
+
+ it('calls Api inviteGroupMember with the correct params', () => {
+ expect(Api.inviteGroupMember).toHaveBeenCalledWith(id, postData);
+ });
});
- it('calls Api inviteGroupMember with the correct params', () => {
- expect(Api.inviteGroupMember).toHaveBeenCalledWith(groupId, postData);
+ describe('when sending the invite for a single member returned an api error', () => {
+ const apiErrorMessage = 'Members already exists';
+
+ beforeEach(() => {
+ wrapper = createComponent({ newUsersToInvite: '123' });
+
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest
+ .spyOn(Api, 'inviteGroupMember')
+ .mockRejectedValue({ response: { data: { message: apiErrorMessage } } });
+
+ findInviteButton().vm.$emit('click');
+ });
+
+ it('displays the api error message for the toastMessage', () => {
+ expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
+ apiErrorMessage,
+ wrapper.vm.toastOptions,
+ );
+ });
});
- describe('when the invite was sent successfully', () => {
- const toastMessageSuccessful = 'Users were succesfully added';
+ describe('when sending the invite for multiple members returned any error', () => {
+ const genericErrorMessage = 'Some of the members could not be added';
- it('displays the successful toastMessage', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ newUsersToInvite: '123' });
+
+ wrapper.vm.$toast = { show: jest.fn() };
+ jest
+ .spyOn(Api, 'inviteGroupMember')
+ .mockRejectedValue({ response: { data: { success: false } } });
+
+ findInviteButton().vm.$emit('click');
+ });
+
+ it('displays the expected toastMessage', () => {
expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(
- toastMessageSuccessful,
+ genericErrorMessage,
wrapper.vm.toastOptions,
);
});
diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js
new file mode 100644
index 00000000000..fb0bd6bb195
--- /dev/null
+++ b/spec/frontend/invite_members/components/members_token_select_spec.js
@@ -0,0 +1,112 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { GlTokenSelector } from '@gitlab/ui';
+import waitForPromises from 'helpers/wait_for_promises';
+import Api from '~/api';
+import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
+
+const label = 'testgroup';
+const placeholder = 'Search for a member';
+const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' };
+const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' };
+const allUsers = [user1, user2];
+
+const createComponent = () => {
+ return shallowMount(MembersTokenSelect, {
+ propsData: {
+ ariaLabelledby: label,
+ placeholder,
+ },
+ });
+};
+
+describe('MembersTokenSelect', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ jest.spyOn(Api, 'users').mockResolvedValue({ data: allUsers });
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTokenSelector = () => wrapper.find(GlTokenSelector);
+
+ describe('rendering the token-selector component', () => {
+ it('renders with the correct props', () => {
+ const expectedProps = {
+ ariaLabelledby: label,
+ placeholder,
+ };
+
+ expect(findTokenSelector().props()).toEqual(expect.objectContaining(expectedProps));
+ });
+ });
+
+ describe('users', () => {
+ describe('when input is focused for the first time (modal auto-focus)', () => {
+ it('does not call the API', async () => {
+ findTokenSelector().vm.$emit('focus');
+
+ await waitForPromises();
+
+ expect(Api.users).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when input is manually focused', () => {
+ it('calls the API and sets dropdown items as request result', async () => {
+ const tokenSelector = findTokenSelector();
+
+ tokenSelector.vm.$emit('focus');
+ tokenSelector.vm.$emit('blur');
+ tokenSelector.vm.$emit('focus');
+
+ await waitForPromises();
+
+ expect(tokenSelector.props('dropdownItems')).toMatchObject(allUsers);
+ expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
+ });
+ });
+
+ describe('when text input is typed in', () => {
+ it('calls the API with search parameter', async () => {
+ const searchParam = 'One';
+ const tokenSelector = findTokenSelector();
+
+ tokenSelector.vm.$emit('text-input', searchParam);
+
+ await waitForPromises();
+
+ expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions);
+ expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
+ });
+ });
+
+ describe('when user is selected', () => {
+ it('emits `input` event with selected users', () => {
+ findTokenSelector().vm.$emit('input', [
+ { id: 1, name: 'John Smith' },
+ { id: 2, name: 'Jane Doe' },
+ ]);
+
+ expect(wrapper.emitted().input[0][0]).toBe('1,2');
+ });
+ });
+ });
+
+ describe('when text input is blurred', () => {
+ it('clears text input', async () => {
+ const tokenSelector = findTokenSelector();
+
+ tokenSelector.vm.$emit('blur');
+
+ await nextTick();
+
+ expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
index f2cb9042ba6..1b4c6b548e2 100644
--- a/spec/frontend/issuable/related_issues/components/issue_token_spec.js
+++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js
@@ -137,9 +137,7 @@ describe('IssueToken', () => {
});
it('tooltip should not be escaped', () => {
- expect(findRemoveBtn().attributes('data-original-title')).toBe(
- `Remove ${displayReference}`,
- );
+ expect(findRemoveBtn().attributes('aria-label')).toBe(`Remove ${displayReference}`);
});
});
});
diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
index b758b85beef..dd05f49b458 100644
--- a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
+++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js
@@ -56,7 +56,7 @@ describe('RelatedIssuesBlock', () => {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
},
- slots: { headerText },
+ slots: { 'header-text': headerText },
});
expect(wrapper.find('.card-title').html()).toContain(headerText);
@@ -72,7 +72,7 @@ describe('RelatedIssuesBlock', () => {
pathIdSeparator: PathIdSeparator.Issue,
issuableType: 'issue',
},
- slots: { headerActions },
+ slots: { 'header-actions': headerActions },
});
expect(wrapper.find('[data-testid="custom-button"]').html()).toBe(headerActions);
diff --git a/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js
new file mode 100644
index 00000000000..52a238eac7c
--- /dev/null
+++ b/spec/frontend/issuable_list/components/issuable_bulk_edit_sidebar_spec.js
@@ -0,0 +1,97 @@
+import { shallowMount } from '@vue/test-utils';
+
+import IssuableBulkEditSidebar from '~/issuable_list/components/issuable_bulk_edit_sidebar.vue';
+
+const createComponent = ({ expanded = true } = {}) =>
+ shallowMount(IssuableBulkEditSidebar, {
+ propsData: {
+ expanded,
+ },
+ slots: {
+ 'bulk-edit-actions': `
+ <button class="js-edit-issuables">Edit issuables</button>
+ `,
+ 'sidebar-items': `
+ <button class="js-sidebar-dropdown">Labels</button>
+ `,
+ },
+ });
+
+describe('IssuableBulkEditSidebar', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ setFixtures('<div class="layout-page right-sidebar-collapsed"></div>');
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('watch', () => {
+ describe('expanded', () => {
+ it.each`
+ expanded | layoutPageClass
+ ${true} | ${'right-sidebar-expanded'}
+ ${false} | ${'right-sidebar-collapsed'}
+ `(
+ 'sets class "$layoutPageClass" on element `.layout-page` when expanded prop is $expanded',
+ async ({ expanded, layoutPageClass }) => {
+ const wrappeCustom = createComponent({
+ expanded: !expanded,
+ });
+
+ // We need to manually flip the value of `expanded` for
+ // watcher to trigger.
+ wrappeCustom.setProps({
+ expanded,
+ });
+
+ await wrappeCustom.vm.$nextTick();
+
+ expect(document.querySelector('.layout-page').classList.contains(layoutPageClass)).toBe(
+ true,
+ );
+
+ wrappeCustom.destroy();
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it.each`
+ expanded | layoutPageClass
+ ${true} | ${'right-sidebar-expanded'}
+ ${false} | ${'right-sidebar-collapsed'}
+ `(
+ 'renders component container with class "$layoutPageClass" when expanded prop is $expanded',
+ async ({ expanded, layoutPageClass }) => {
+ const wrappeCustom = createComponent({
+ expanded: !expanded,
+ });
+
+ // We need to manually flip the value of `expanded` for
+ // watcher to trigger.
+ wrappeCustom.setProps({
+ expanded,
+ });
+
+ await wrappeCustom.vm.$nextTick();
+
+ expect(wrappeCustom.classes()).toContain(layoutPageClass);
+
+ wrappeCustom.destroy();
+ },
+ );
+
+ it('renders contents for slot `bulk-edit-actions`', () => {
+ expect(wrapper.find('button.js-edit-issuables').exists()).toBe(true);
+ });
+
+ it('renders contents for slot `sidebar-items`', () => {
+ expect(wrapper.find('button.js-sidebar-dropdown').exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js
index a96a4e15e6c..3a9a0d3fd59 100644
--- a/spec/frontend/issuable_list/components/issuable_item_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_item_spec.js
@@ -1,29 +1,37 @@
import { shallowMount } from '@vue/test-utils';
-import { GlLink, GlLabel } from '@gitlab/ui';
+import { GlLink, GlLabel, GlIcon, GlFormCheckbox } from '@gitlab/ui';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
+import IssuableAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data';
-const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) =>
+const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable, slots = {} } = {}) =>
shallowMount(IssuableItem, {
propsData: {
issuableSymbol,
issuable,
+ enableLabelPermalinks: true,
+ showDiscussions: true,
+ showCheckbox: false,
},
+ slots,
});
describe('IssuableItem', () => {
const mockLabels = mockIssuable.labels.nodes;
const mockAuthor = mockIssuable.author;
+ const originalUrl = gon.gitlab_url;
let wrapper;
beforeEach(() => {
+ gon.gitlab_url = 'http://0.0.0.0:3000';
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
+ gon.gitlab_url = originalUrl;
});
describe('computed', () => {
@@ -38,8 +46,8 @@ describe('IssuableItem', () => {
authorId | returnValue
${1} | ${1}
${'1'} | ${1}
- ${'gid://gitlab/User/1'} | ${'1'}
- ${'foo'} | ${''}
+ ${'gid://gitlab/User/1'} | ${1}
+ ${'foo'} | ${null}
`(
'returns $returnValue when value of `issuable.author.id` is $authorId',
async ({ authorId, returnValue }) => {
@@ -60,6 +68,30 @@ describe('IssuableItem', () => {
);
});
+ describe('isIssuableUrlExternal', () => {
+ it.each`
+ issuableWebUrl | urlType | returnValue
+ ${'/gitlab-org/gitlab-test/-/issues/2'} | ${'relative'} | ${false}
+ ${'http://0.0.0.0:3000/gitlab-org/gitlab-test/-/issues/1'} | ${'absolute and internal'} | ${false}
+ ${'http://jira.atlassian.net/browse/IG-1'} | ${'external'} | ${true}
+ ${'https://github.com/gitlabhq/gitlabhq/issues/1'} | ${'external'} | ${true}
+ `(
+ 'returns $returnValue when `issuable.webUrl` is $urlType',
+ async ({ issuableWebUrl, returnValue }) => {
+ wrapper.setProps({
+ issuable: {
+ ...mockIssuable,
+ webUrl: issuableWebUrl,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.isIssuableUrlExternal).toBe(returnValue);
+ },
+ );
+ });
+
describe('labels', () => {
it('returns `issuable.labels.nodes` reference when it is available', () => {
expect(wrapper.vm.labels).toEqual(mockLabels);
@@ -92,6 +124,12 @@ describe('IssuableItem', () => {
});
});
+ describe('assignees', () => {
+ it('returns `issuable.assignees` reference when it is available', () => {
+ expect(wrapper.vm.assignees).toBe(mockIssuable.assignees);
+ });
+ });
+
describe('createdAt', () => {
it('returns string containing timeago string based on `issuable.createdAt`', () => {
expect(wrapper.vm.createdAt).toContain('created');
@@ -105,6 +143,31 @@ describe('IssuableItem', () => {
expect(wrapper.vm.updatedAt).toContain('ago');
});
});
+
+ describe('showDiscussions', () => {
+ it.each`
+ userDiscussionsCount | returnValue
+ ${0} | ${true}
+ ${1} | ${true}
+ ${undefined} | ${false}
+ ${null} | ${false}
+ `(
+ 'returns $returnValue when issuable.userDiscussionsCount is $userDiscussionsCount',
+ ({ userDiscussionsCount, returnValue }) => {
+ const wrapperWithDiscussions = createComponent({
+ issuableSymbol: '#',
+ issuable: {
+ ...mockIssuable,
+ userDiscussionsCount,
+ },
+ });
+
+ expect(wrapperWithDiscussions.vm.showDiscussions).toBe(returnValue);
+
+ wrapperWithDiscussions.destroy();
+ },
+ );
+ });
});
describe('methods', () => {
@@ -120,6 +183,34 @@ describe('IssuableItem', () => {
},
);
});
+
+ describe('labelTitle', () => {
+ it.each`
+ label | propWithTitle | returnValue
+ ${{ title: 'foo' }} | ${'title'} | ${'foo'}
+ ${{ name: 'foo' }} | ${'name'} | ${'foo'}
+ `('returns string value of `label.$propWithTitle`', ({ label, returnValue }) => {
+ expect(wrapper.vm.labelTitle(label)).toBe(returnValue);
+ });
+ });
+
+ describe('labelTarget', () => {
+ it('returns target string for a provided label param when `enableLabelPermalinks` is true', () => {
+ expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe(
+ '?label_name%5B%5D=Documentation%20Update',
+ );
+ });
+
+ it('returns string "#" for a provided label param when `enableLabelPermalinks` is false', async () => {
+ wrapper.setProps({
+ enableLabelPermalinks: false,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.labelTarget(mockRegularLabel)).toBe('#');
+ });
+ });
});
describe('template', () => {
@@ -128,9 +219,47 @@ describe('IssuableItem', () => {
expect(titleEl.exists()).toBe(true);
expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl);
+ expect(titleEl.find(GlLink).attributes('target')).not.toBeDefined();
expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title);
});
+ it('renders checkbox when `showCheckbox` prop is true', async () => {
+ wrapper.setProps({
+ showCheckbox: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlFormCheckbox).exists()).toBe(true);
+ expect(wrapper.find(GlFormCheckbox).attributes('checked')).not.toBeDefined();
+
+ wrapper.setProps({
+ checked: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(GlFormCheckbox).attributes('checked')).toBe('true');
+ });
+
+ it('renders issuable title with `target` set as "_blank" when issuable.webUrl is external', async () => {
+ wrapper.setProps({
+ issuable: {
+ ...mockIssuable,
+ webUrl: 'http://jira.atlassian.net/browse/IG-1',
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ wrapper
+ .find('[data-testid="issuable-title"]')
+ .find(GlLink)
+ .attributes('target'),
+ ).toBe('_blank');
+ });
+
it('renders issuable reference', () => {
const referenceEl = wrapper.find('[data-testid="issuable-reference"]');
@@ -138,6 +267,24 @@ describe('IssuableItem', () => {
expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`);
});
+ it('renders issuable reference via slot', () => {
+ const wrapperWithRefSlot = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ reference: `
+ <b class="js-reference">${mockIssuable.iid}</b>
+ `,
+ },
+ });
+ const referenceEl = wrapperWithRefSlot.find('.js-reference');
+
+ expect(referenceEl.exists()).toBe(true);
+ expect(referenceEl.text()).toBe(`${mockIssuable.iid}`);
+
+ wrapperWithRefSlot.destroy();
+ });
+
it('renders issuable createdAt info', () => {
const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]');
@@ -151,7 +298,7 @@ describe('IssuableItem', () => {
expect(authorEl.exists()).toBe(true);
expect(authorEl.attributes()).toMatchObject({
- 'data-user-id': wrapper.vm.authorId,
+ 'data-user-id': `${wrapper.vm.authorId}`,
'data-username': mockAuthor.username,
'data-name': mockAuthor.name,
'data-avatar-url': mockAuthor.avatarUrl,
@@ -160,6 +307,42 @@ describe('IssuableItem', () => {
expect(authorEl.text()).toBe(mockAuthor.name);
});
+ it('renders issuable author info via slot', () => {
+ const wrapperWithAuthorSlot = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ reference: `
+ <span class="js-author">${mockAuthor.name}</span>
+ `,
+ },
+ });
+ const authorEl = wrapperWithAuthorSlot.find('.js-author');
+
+ expect(authorEl.exists()).toBe(true);
+ expect(authorEl.text()).toBe(mockAuthor.name);
+
+ wrapperWithAuthorSlot.destroy();
+ });
+
+ it('renders timeframe via slot', () => {
+ const wrapperWithTimeframeSlot = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ timeframe: `
+ <b class="js-timeframe">Jan 1, 2020 - Mar 31, 2020</b>
+ `,
+ },
+ });
+ const timeframeEl = wrapperWithTimeframeSlot.find('.js-timeframe');
+
+ expect(timeframeEl.exists()).toBe(true);
+ expect(timeframeEl.text()).toBe('Jan 1, 2020 - Mar 31, 2020');
+
+ wrapperWithTimeframeSlot.destroy();
+ });
+
it('renders gl-label component for each label present within `issuable` prop', () => {
const labelsEl = wrapper.findAll(GlLabel);
@@ -170,10 +353,52 @@ describe('IssuableItem', () => {
title: mockLabels[0].title,
description: mockLabels[0].description,
scoped: false,
+ target: wrapper.vm.labelTarget(mockLabels[0]),
size: 'sm',
});
});
+ it('renders issuable status via slot', () => {
+ const wrapperWithStatusSlot = createComponent({
+ issuableSymbol: '#',
+ issuable: mockIssuable,
+ slots: {
+ status: `
+ <b class="js-status">${mockIssuable.state}</b>
+ `,
+ },
+ });
+ const statusEl = wrapperWithStatusSlot.find('.js-status');
+
+ expect(statusEl.exists()).toBe(true);
+ expect(statusEl.text()).toBe(`${mockIssuable.state}`);
+
+ wrapperWithStatusSlot.destroy();
+ });
+
+ it('renders discussions count', () => {
+ const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]');
+
+ expect(discussionsEl.exists()).toBe(true);
+ expect(discussionsEl.find(GlLink).attributes()).toMatchObject({
+ title: 'Comments',
+ href: `${mockIssuable.webUrl}#notes`,
+ });
+ expect(discussionsEl.find(GlIcon).props('name')).toBe('comments');
+ expect(discussionsEl.find(GlLink).text()).toContain('2');
+ });
+
+ it('renders issuable-assignees component', () => {
+ const assigneesEl = wrapper.find(IssuableAssignees);
+
+ expect(assigneesEl.exists()).toBe(true);
+ expect(assigneesEl.props()).toMatchObject({
+ assignees: mockIssuable.assignees,
+ iconSize: 16,
+ maxVisible: 4,
+ });
+ });
+
it('renders issuable updatedAt info', () => {
const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]');
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
index 34184522b55..add5d9e8e2d 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -1,16 +1,21 @@
import { mount } from '@vue/test-utils';
-import { GlLoadingIcon, GlPagination } from '@gitlab/ui';
+import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+
+import { TEST_HOST } from 'helpers/test_constants';
import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue';
import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue';
import IssuableItem from '~/issuable_list/components/issuable_item.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
-import { mockIssuableListProps } from '../mock_data';
+import { mockIssuableListProps, mockIssuables } from '../mock_data';
-const createComponent = (propsData = mockIssuableListProps) =>
+const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
mount(IssuableListRoot, {
- propsData,
+ propsData: props,
+ data() {
+ return data;
+ },
slots: {
'nav-actions': `
<button class="js-new-issuable">New issuable</button>
@@ -32,6 +37,139 @@ describe('IssuableListRoot', () => {
wrapper.destroy();
});
+ describe('computed', () => {
+ const mockCheckedIssuables = {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ [mockIssuables[1].iid]: { checked: true, issuable: mockIssuables[1] },
+ [mockIssuables[2].iid]: { checked: true, issuable: mockIssuables[2] },
+ };
+
+ const mIssuables = [mockIssuables[0], mockIssuables[1], mockIssuables[2]];
+
+ describe('skeletonItemCount', () => {
+ it.each`
+ totalItems | defaultPageSize | currentPage | returnValue
+ ${100} | ${20} | ${1} | ${20}
+ ${105} | ${20} | ${6} | ${5}
+ ${7} | ${20} | ${1} | ${7}
+ ${0} | ${20} | ${1} | ${5}
+ `(
+ 'returns $returnValue when totalItems is $totalItems, defaultPageSize is $defaultPageSize and currentPage is $currentPage',
+ async ({ totalItems, defaultPageSize, currentPage, returnValue }) => {
+ wrapper.setProps({
+ totalItems,
+ defaultPageSize,
+ currentPage,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.skeletonItemCount).toBe(returnValue);
+ },
+ );
+ });
+
+ describe('allIssuablesChecked', () => {
+ it.each`
+ checkedIssuables | issuables | specTitle | returnValue
+ ${mockCheckedIssuables} | ${mIssuables} | ${'same as'} | ${true}
+ ${{}} | ${mIssuables} | ${'not same as'} | ${false}
+ `(
+ 'returns $returnValue when bulkEditIssuables count is $specTitle issuables count',
+ async ({ checkedIssuables, issuables, returnValue }) => {
+ wrapper.setProps({
+ issuables,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ wrapper.setData({
+ checkedIssuables,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.allIssuablesChecked).toBe(returnValue);
+ },
+ );
+ });
+
+ describe('bulkEditIssuables', () => {
+ it('returns array of issuables which have `checked` set to true within checkedIssuables map', async () => {
+ wrapper.setData({
+ checkedIssuables: mockCheckedIssuables,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.bulkEditIssuables).toHaveLength(mIssuables.length);
+ });
+ });
+ });
+
+ describe('watch', () => {
+ describe('issuables', () => {
+ it('populates `checkedIssuables` prop with all issuables', async () => {
+ wrapper.setProps({
+ issuables: [mockIssuables[0]],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(Object.keys(wrapper.vm.checkedIssuables)).toHaveLength(1);
+ expect(wrapper.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
+ checked: false,
+ issuable: mockIssuables[0],
+ });
+ });
+ });
+
+ describe('urlParams', () => {
+ it('updates window URL reflecting props within `urlParams`', async () => {
+ const urlParams = {
+ state: 'closed',
+ sort: 'updated_asc',
+ page: 1,
+ search: 'foo',
+ };
+
+ wrapper.setProps({
+ urlParams,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(global.window.location.href).toBe(
+ `${TEST_HOST}/?state=${urlParams.state}&sort=${urlParams.sort}&page=${urlParams.page}&search=${urlParams.search}`,
+ );
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('issuableId', () => {
+ it('returns id value from provided issuable object', () => {
+ expect(wrapper.vm.issuableId({ id: 1 })).toBe(1);
+ expect(wrapper.vm.issuableId({ iid: 1 })).toBe(1);
+ expect(wrapper.vm.issuableId({})).toBeDefined();
+ });
+ });
+
+ describe('issuableChecked', () => {
+ it('returns boolean value representing checked status of issuable item', async () => {
+ wrapper.setData({
+ checkedIssuables: {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.issuableChecked(mockIssuables[0])).toBe(true);
+ });
+ });
+ });
+
describe('template', () => {
it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
@@ -86,7 +224,7 @@ describe('IssuableListRoot', () => {
await wrapper.vm.$nextTick();
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ expect(wrapper.findAll(GlSkeletonLoading)).toHaveLength(wrapper.vm.skeletonItemCount);
});
it('renders issuable-item component for each item within `issuables` array', () => {
@@ -114,6 +252,7 @@ describe('IssuableListRoot', () => {
it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
wrapper.setProps({
showPaginationControls: true,
+ totalItems: 10,
});
await wrapper.vm.$nextTick();
@@ -125,18 +264,51 @@ describe('IssuableListRoot', () => {
value: 1,
prevPage: 0,
nextPage: 2,
+ totalItems: 10,
align: 'center',
});
});
});
describe('events', () => {
+ let wrapperChecked;
+
+ beforeEach(() => {
+ wrapperChecked = createComponent({
+ data: {
+ checkedIssuables: {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapperChecked.destroy();
+ });
+
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
wrapper.find(IssuableTabs).vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
+ it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
+ const searchEl = wrapperChecked.find(FilteredSearchBar);
+
+ searchEl.vm.$emit('checked-input', true);
+
+ await wrapperChecked.vm.$nextTick();
+
+ expect(searchEl.emitted('checked-input')).toBeTruthy();
+ expect(searchEl.emitted('checked-input').length).toBe(1);
+
+ expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
+ checked: true,
+ issuable: mockIssuables[0],
+ });
+ });
+
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
const searchEl = wrapper.find(FilteredSearchBar);
@@ -146,6 +318,22 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
+ it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
+ const issuableItem = wrapperChecked.findAll(IssuableItem).at(0);
+
+ issuableItem.vm.$emit('checked-input', true);
+
+ await wrapperChecked.vm.$nextTick();
+
+ expect(issuableItem.emitted('checked-input')).toBeTruthy();
+ expect(issuableItem.emitted('checked-input').length).toBe(1);
+
+ expect(wrapperChecked.vm.checkedIssuables[mockIssuables[0].iid]).toEqual({
+ checked: true,
+ issuable: mockIssuables[0],
+ });
+ });
+
it('gl-pagination component emits `page-change` event on `input` event', async () => {
wrapper.setProps({
showPaginationControls: true,
diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js
index 8eab2ca6f94..e19a337473a 100644
--- a/spec/frontend/issuable_list/mock_data.js
+++ b/spec/frontend/issuable_list/mock_data.js
@@ -51,6 +51,8 @@ export const mockIssuable = {
labels: {
nodes: mockLabels,
},
+ assignees: [mockAuthor],
+ userDiscussionsCount: 2,
};
export const mockIssuables = [
diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js
index f4095d4de96..dde4e8458d5 100644
--- a/spec/frontend/issue_show/components/app_spec.js
+++ b/spec/frontend/issue_show/components/app_spec.js
@@ -17,6 +17,7 @@ import {
import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
+import { IssuableStatus, IssuableStatusText } from '~/issue_show/constants';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
@@ -36,6 +37,10 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
+ const findLockedBadge = () => wrapper.find('[data-testid="locked"]');
+
+ const findConfidentialBadge = () => wrapper.find('[data-testid="confidential"]');
+
const mountComponent = (props = {}, options = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...appProps, ...props },
@@ -532,7 +537,7 @@ describe('Issuable output', () => {
describe('sticky header', () => {
describe('when title is in view', () => {
it('is not shown', () => {
- expect(wrapper.find('.issue-sticky-header').exists()).toBe(false);
+ expect(findStickyHeader().exists()).toBe(false);
});
});
@@ -542,24 +547,45 @@ describe('Issuable output', () => {
wrapper.find(GlIntersectionObserver).vm.$emit('disappear');
});
- it('is shown with title', () => {
+ it('shows with title', () => {
expect(findStickyHeader().text()).toContain('Sticky header title');
});
- it('is shown with Open when status is opened', () => {
- wrapper.setProps({ issuableStatus: 'opened' });
+ it.each`
+ title | state
+ ${'shows with Open when status is opened'} | ${IssuableStatus.Open}
+ ${'shows with Closed when status is closed'} | ${IssuableStatus.Closed}
+ ${'shows with Open when status is reopened'} | ${IssuableStatus.Reopened}
+ `('$title', async ({ state }) => {
+ wrapper.setProps({ issuableStatus: state });
- return wrapper.vm.$nextTick(() => {
- expect(findStickyHeader().text()).toContain('Open');
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findStickyHeader().text()).toContain(IssuableStatusText[state]);
});
- it('is shown with Closed when status is closed', () => {
- wrapper.setProps({ issuableStatus: 'closed' });
+ it.each`
+ title | isConfidential
+ ${'does not show confidential badge when issue is not confidential'} | ${true}
+ ${'shows confidential badge when issue is confidential'} | ${false}
+ `('$title', async ({ isConfidential }) => {
+ wrapper.setProps({ isConfidential });
- return wrapper.vm.$nextTick(() => {
- expect(findStickyHeader().text()).toContain('Closed');
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findConfidentialBadge().exists()).toBe(isConfidential);
+ });
+
+ it.each`
+ title | isLocked
+ ${'does not show locked badge when issue is not locked'} | ${true}
+ ${'shows locked badge when issue is locked'} | ${false}
+ `('$title', async ({ isLocked }) => {
+ wrapper.setProps({ isLocked });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findLockedBadge().exists()).toBe(isLocked);
});
});
});
diff --git a/spec/frontend/issue_show/components/header_actions_spec.js b/spec/frontend/issue_show/components/header_actions_spec.js
new file mode 100644
index 00000000000..67b8665a889
--- /dev/null
+++ b/spec/frontend/issue_show/components/header_actions_spec.js
@@ -0,0 +1,328 @@
+import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import Vuex from 'vuex';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import { IssuableType } from '~/issuable_show/constants';
+import HeaderActions from '~/issue_show/components/header_actions.vue';
+import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
+import promoteToEpicMutation from '~/issue_show/queries/promote_to_epic.mutation.graphql';
+import * as urlUtility from '~/lib/utils/url_utility';
+import createStore from '~/notes/stores';
+
+jest.mock('~/flash');
+
+describe('HeaderActions component', () => {
+ let dispatchEventSpy;
+ let mutateMock;
+ let wrapper;
+ let visitUrlSpy;
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+ const store = createStore();
+
+ const defaultProps = {
+ canCreateIssue: true,
+ canPromoteToEpic: true,
+ canReopenIssue: true,
+ canReportSpam: true,
+ canUpdateIssue: true,
+ iid: '32',
+ isIssueAuthor: true,
+ issueType: IssuableType.Issue,
+ newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
+ projectPath: 'gitlab-org/gitlab-test',
+ reportAbusePath:
+ '-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1',
+ submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
+ };
+
+ const updateIssueMutationResponse = { data: { updateIssue: { errors: [] } } };
+
+ const promoteToEpicMutationResponse = {
+ data: {
+ promoteToEpic: {
+ errors: [],
+ epic: {
+ webPath: '/groups/gitlab-org/-/epics/1',
+ },
+ },
+ },
+ };
+
+ const promoteToEpicMutationErrorResponse = {
+ data: {
+ promoteToEpic: {
+ errors: ['The issue has already been promoted to an epic.'],
+ epic: {},
+ },
+ },
+ };
+
+ const findToggleIssueStateButton = () => wrapper.find(GlButton);
+
+ const findDropdownAt = index => wrapper.findAll(GlDropdown).at(index);
+
+ const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem);
+
+ const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem);
+
+ const findModal = () => wrapper.find(GlModal);
+
+ const findModalLinkAt = index =>
+ findModal()
+ .findAll(GlLink)
+ .at(index);
+
+ const mountComponent = ({
+ props = {},
+ issueState = IssuableStatus.Open,
+ blockedByIssues = [],
+ mutateResponse = {},
+ } = {}) => {
+ mutateMock = jest.fn().mockResolvedValue(mutateResponse);
+
+ store.getters.getNoteableData.state = issueState;
+ store.getters.getNoteableData.blocked_by_issues = blockedByIssues;
+
+ return shallowMount(HeaderActions, {
+ localVue,
+ store,
+ provide: {
+ ...defaultProps,
+ ...props,
+ },
+ mocks: {
+ $apollo: {
+ mutate: mutateMock,
+ },
+ },
+ });
+ };
+
+ afterEach(() => {
+ if (dispatchEventSpy) {
+ dispatchEventSpy.mockRestore();
+ }
+ if (visitUrlSpy) {
+ visitUrlSpy.mockRestore();
+ }
+ wrapper.destroy();
+ });
+
+ describe.each`
+ issueType
+ ${IssuableType.Issue}
+ ${IssuableType.Incident}
+ `('when issue type is $issueType', ({ issueType }) => {
+ describe('close/reopen button', () => {
+ describe.each`
+ description | issueState | buttonText | newIssueState
+ ${`when the ${issueType} is open`} | ${IssuableStatus.Open} | ${`Close ${issueType}`} | ${IssueStateEvent.Close}
+ ${`when the ${issueType} is closed`} | ${IssuableStatus.Closed} | ${`Reopen ${issueType}`} | ${IssueStateEvent.Reopen}
+ `('$description', ({ issueState, buttonText, newIssueState }) => {
+ beforeEach(() => {
+ dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
+
+ wrapper = mountComponent({
+ props: { issueType },
+ issueState,
+ mutateResponse: updateIssueMutationResponse,
+ });
+ });
+
+ it(`has text "${buttonText}"`, () => {
+ expect(findToggleIssueStateButton().text()).toBe(buttonText);
+ });
+
+ it('calls apollo mutation', () => {
+ findToggleIssueStateButton().vm.$emit('click');
+
+ expect(mutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: {
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ stateEvent: newIssueState,
+ },
+ },
+ }),
+ );
+ });
+
+ it('dispatches a custom event to update the issue page', async () => {
+ findToggleIssueStateButton().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe.each`
+ description | isCloseIssueItemVisible | findDropdownItems
+ ${'mobile dropdown'} | ${true} | ${findMobileDropdownItems}
+ ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
+ `('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
+ describe.each`
+ description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic
+ ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true}
+ ${`when user can create ${issueType}`} | ${`New ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${`when user cannot create ${issueType}`} | ${`New ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true}
+ ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false}
+ ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true}
+ ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true}
+ ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true}
+ `(
+ '$description',
+ ({
+ itemText,
+ isItemVisible,
+ canUpdateIssue,
+ canCreateIssue,
+ isIssueAuthor,
+ canReportSpam,
+ canPromoteToEpic,
+ }) => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ canUpdateIssue,
+ canCreateIssue,
+ isIssueAuthor,
+ issueType,
+ canReportSpam,
+ canPromoteToEpic,
+ },
+ });
+ });
+
+ it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => {
+ expect(
+ findDropdownItems()
+ .filter(item => item.text() === itemText)
+ .exists(),
+ ).toBe(isItemVisible);
+ });
+ },
+ );
+ });
+ });
+
+ describe('when "Promote to epic" button is clicked', () => {
+ describe('when response is successful', () => {
+ beforeEach(() => {
+ visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+
+ wrapper = mountComponent({
+ mutateResponse: promoteToEpicMutationResponse,
+ });
+
+ wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+ });
+
+ it('invokes GraphQL mutation when clicked', () => {
+ expect(mutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mutation: promoteToEpicMutation,
+ variables: {
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ },
+ },
+ }),
+ );
+ });
+
+ it('shows a success message and tells the user they are being redirected', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'The issue was successfully promoted to an epic. Redirecting to epic...',
+ type: FLASH_TYPES.SUCCESS,
+ });
+ });
+
+ it('redirects to newly created epic path', () => {
+ expect(visitUrlSpy).toHaveBeenCalledWith(
+ promoteToEpicMutationResponse.data.promoteToEpic.epic.webPath,
+ );
+ });
+ });
+
+ describe('when response contains errors', () => {
+ beforeEach(() => {
+ visitUrlSpy = jest.spyOn(urlUtility, 'visitUrl').mockReturnValue({});
+
+ wrapper = mountComponent({
+ mutateResponse: promoteToEpicMutationErrorResponse,
+ });
+
+ wrapper.find('[data-testid="promote-button"]').vm.$emit('click');
+ });
+
+ it('shows an error message', () => {
+ expect(createFlash).toHaveBeenCalledWith({
+ message: promoteToEpicMutationErrorResponse.data.promoteToEpic.errors.join('; '),
+ });
+ });
+ });
+ });
+
+ describe('modal', () => {
+ const blockedByIssues = [
+ { iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
+ { iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' },
+ ];
+
+ beforeEach(() => {
+ wrapper = mountComponent({ blockedByIssues });
+ });
+
+ it('has title text', () => {
+ expect(findModal().attributes('title')).toBe(
+ 'Are you sure you want to close this blocked issue?',
+ );
+ });
+
+ it('has body text', () => {
+ expect(findModal().text()).toContain(
+ 'This issue is currently blocked by the following issues:',
+ );
+ });
+
+ it('calls apollo mutation when primary button is clicked', () => {
+ findModal().vm.$emit('primary');
+
+ expect(mutateMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variables: {
+ input: {
+ iid: defaultProps.iid.toString(),
+ projectPath: defaultProps.projectPath,
+ stateEvent: IssueStateEvent.Close,
+ },
+ },
+ }),
+ );
+ });
+
+ describe.each`
+ ordinal | index
+ ${'first'} | ${0}
+ ${'second'} | ${1}
+ `('$ordinal blocked-by issue link', ({ index }) => {
+ it('has link text', () => {
+ expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`);
+ });
+
+ it('has url', () => {
+ expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js
index c0175e774a2..7a48353af94 100644
--- a/spec/frontend/issue_show/issue_spec.js
+++ b/spec/frontend/issue_show/issue_spec.js
@@ -2,9 +2,10 @@ import MockAdapter from 'axios-mock-adapter';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
-import initIssuableApp from '~/issue_show/issue';
+import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data';
import { appProps } from './mock_data';
+import createStore from '~/notes/stores';
const mock = new MockAdapter(axios);
mock.onGet().reply(200);
@@ -30,7 +31,7 @@ describe('Issue show index', () => {
});
const issuableData = parseData.parseIssuableData();
- initIssuableApp(issuableData);
+ initIssuableApp(issuableData, createStore());
await waitForPromises();
diff --git a/spec/frontend/issues_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js
index c20684cc385..6e584152551 100644
--- a/spec/frontend/issues_list/components/issuable_spec.js
+++ b/spec/frontend/issues_list/components/issuable_spec.js
@@ -38,7 +38,7 @@ describe('Issuable component', () => {
let DateOrig;
let wrapper;
- const factory = (props = {}, scopedLabels = false) => {
+ const factory = (props = {}, scopedLabelsAvailable = false) => {
wrapper = shallowMount(Issuable, {
propsData: {
issuable: simpleIssue,
@@ -46,9 +46,7 @@ describe('Issuable component', () => {
...props,
},
provide: {
- glFeatures: {
- scopedLabels,
- },
+ scopedLabelsAvailable,
},
stubs: {
'gl-sprintf': GlSprintf,
diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
index cd0266068aa..fe6d9a34078 100644
--- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
+++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap
@@ -91,6 +91,8 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
>
<!---->
+ <!---->
+
<span
class="gl-new-dropdown-button-text"
>
@@ -98,6 +100,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
</span>
<svg
+ aria-hidden="true"
class="gl-button-icon dropdown-chevron gl-icon s16"
data-testid="chevron-down-icon"
>
@@ -202,6 +205,8 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
>
<!---->
+ <!---->
+
<span
class="gl-new-dropdown-button-text"
>
@@ -209,6 +214,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] =
</span>
<svg
+ aria-hidden="true"
class="gl-button-icon dropdown-chevron gl-icon s16"
data-testid="chevron-down-icon"
>
diff --git a/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js
new file mode 100644
index 00000000000..08973223c08
--- /dev/null
+++ b/spec/frontend/jobs/components/job_retry_forward_deployment_modal_spec.js
@@ -0,0 +1,76 @@
+import { GlLink, GlModal } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue';
+import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants';
+import createStore from '~/jobs/store';
+import job from '../mock_data';
+
+describe('Job Retry Forward Deployment Modal', () => {
+ let store;
+ let wrapper;
+
+ const retryOutdatedJobDocsUrl = 'url-to-docs';
+ const findLink = () => wrapper.find(GlLink);
+ const findModal = () => wrapper.find(GlModal);
+
+ const createWrapper = ({ props = {}, provide = {}, stubs = {} } = {}) => {
+ store = createStore();
+ wrapper = shallowMount(JobRetryForwardDeploymentModal, {
+ propsData: {
+ modalId: 'modal-id',
+ href: job.retry_path,
+ ...props,
+ },
+ provide,
+ store,
+ stubs,
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ beforeEach(createWrapper);
+
+ describe('Modal configuration', () => {
+ it('should display the correct messages', () => {
+ const modal = findModal();
+ expect(modal.attributes('title')).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.title);
+ expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.info);
+ expect(modal.text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.areYouSure);
+ });
+ });
+
+ describe('Modal docs help link', () => {
+ it('should not display an info link when none is provided', () => {
+ createWrapper();
+
+ expect(findLink().exists()).toBe(false);
+ });
+
+ it('should display an info link when one is provided', () => {
+ createWrapper({ provide: { retryOutdatedJobDocsUrl } });
+
+ expect(findLink().attributes('href')).toBe(retryOutdatedJobDocsUrl);
+ expect(findLink().text()).toMatch(JOB_RETRY_FORWARD_DEPLOYMENT_MODAL.moreInfo);
+ });
+ });
+
+ describe('Modal actions', () => {
+ beforeEach(createWrapper);
+
+ it('should correctly configure the primary action', () => {
+ expect(findModal().props('actionPrimary').attributes).toMatchObject([
+ {
+ 'data-method': 'post',
+ href: job.retry_path,
+ variant: 'danger',
+ },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_sidebar_details_container_spec.js b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
new file mode 100644
index 00000000000..be684769b46
--- /dev/null
+++ b/spec/frontend/jobs/components/job_sidebar_details_container_spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import SidebarJobDetailsContainer from '~/jobs/components/sidebar_job_details_container.vue';
+import DetailRow from '~/jobs/components/sidebar_detail_row.vue';
+import createStore from '~/jobs/store';
+import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
+import job from '../mock_data';
+
+describe('Job Sidebar Details Container', () => {
+ let store;
+ let wrapper;
+
+ const findJobTimeout = () => wrapper.findByTestId('job-timeout');
+ const findJobTags = () => wrapper.findByTestId('job-tags');
+ const findAllDetailsRow = () => wrapper.findAll(DetailRow);
+
+ const createWrapper = ({ props = {} } = {}) => {
+ store = createStore();
+ wrapper = extendedWrapper(
+ shallowMount(SidebarJobDetailsContainer, {
+ propsData: props,
+ store,
+ stubs: {
+ DetailRow,
+ },
+ }),
+ );
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ describe('when no details are available', () => {
+ it('should render an empty container', () => {
+ createWrapper();
+
+ expect(wrapper.isEmpty()).toBe(true);
+ });
+ });
+
+ describe('when some of the details are available', () => {
+ beforeEach(createWrapper);
+
+ it.each([
+ ['duration', 'Duration: 6 seconds'],
+ ['erased_at', 'Erased: 3 weeks ago'],
+ ['finished_at', 'Finished: 3 weeks ago'],
+ ['queued', 'Queued: 9 seconds'],
+ ['runner', 'Runner: local ci runner (#1)'],
+ ['coverage', 'Coverage: 20%'],
+ ])('uses %s to render job-%s', async (detail, value) => {
+ await store.dispatch('receiveJobSuccess', { [detail]: job[detail] });
+ const detailsRow = findAllDetailsRow();
+
+ expect(wrapper.isEmpty()).toBe(false);
+ expect(detailsRow).toHaveLength(1);
+ expect(detailsRow.at(0).text()).toBe(value);
+ });
+
+ it('only renders tags', async () => {
+ const { tags } = job;
+ await store.dispatch('receiveJobSuccess', { tags });
+ const tagsComponent = findJobTags();
+
+ expect(wrapper.isEmpty()).toBe(false);
+ expect(tagsComponent.text()).toBe('Tags: tag');
+ });
+ });
+
+ describe('when all the info are available', () => {
+ it('renders all the details components', async () => {
+ createWrapper();
+ await store.dispatch('receiveJobSuccess', job);
+
+ expect(findAllDetailsRow()).toHaveLength(7);
+ });
+ });
+
+ describe('timeout', () => {
+ const {
+ metadata: { timeout_human_readable, timeout_source },
+ } = job;
+
+ beforeEach(createWrapper);
+
+ it('does not render if metadata is empty', async () => {
+ const metadata = {};
+ await store.dispatch('receiveJobSuccess', { metadata });
+ const detailsRow = findAllDetailsRow();
+
+ expect(wrapper.isEmpty()).toBe(true);
+ expect(detailsRow.exists()).toBe(false);
+ });
+
+ it('uses metadata to render timeout', async () => {
+ const metadata = { timeout_human_readable };
+ await store.dispatch('receiveJobSuccess', { metadata });
+ const detailsRow = findAllDetailsRow();
+
+ expect(wrapper.isEmpty()).toBe(false);
+ expect(detailsRow).toHaveLength(1);
+ expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s');
+ });
+
+ it('uses metadata to render timeout and the source', async () => {
+ const metadata = { timeout_human_readable, timeout_source };
+ await store.dispatch('receiveJobSuccess', { metadata });
+ const detailsRow = findAllDetailsRow();
+
+ expect(detailsRow.at(0).text()).toBe('Timeout: 1m 40s (from runner)');
+ });
+
+ it('should not render when no time is provided', async () => {
+ const metadata = { timeout_source };
+ await store.dispatch('receiveJobSuccess', { metadata });
+
+ expect(findJobTimeout().exists()).toBe(false);
+ });
+
+ it('should pass the help URL', async () => {
+ const helpUrl = 'fakeUrl';
+ const props = { runnerHelpUrl: helpUrl };
+ createWrapper({ props });
+ await store.dispatch('receiveJobSuccess', { metadata: { timeout_human_readable } });
+
+ expect(findJobTimeout().props('helpUrl')).toBe(helpUrl);
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
new file mode 100644
index 00000000000..4bf697ab7cc
--- /dev/null
+++ b/spec/frontend/jobs/components/job_sidebar_retry_button_spec.js
@@ -0,0 +1,70 @@
+import { GlButton, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import job from '../mock_data';
+import JobsSidebarRetryButton from '~/jobs/components/job_sidebar_retry_button.vue';
+import createStore from '~/jobs/store';
+
+describe('Job Sidebar Retry Button', () => {
+ let store;
+ let wrapper;
+
+ const forwardDeploymentFailure = 'forward_deployment_failure';
+ const findRetryButton = () => wrapper.find(GlButton);
+ const findRetryLink = () => wrapper.find(GlLink);
+
+ const createWrapper = ({ props = {} } = {}) => {
+ store = createStore();
+ wrapper = shallowMount(JobsSidebarRetryButton, {
+ propsData: {
+ href: job.retry_path,
+ modalId: 'modal-id',
+ ...props,
+ },
+ store,
+ });
+ };
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
+ });
+
+ beforeEach(createWrapper);
+
+ it.each([
+ [null, false, true],
+ ['unmet_prerequisites', false, true],
+ [forwardDeploymentFailure, true, false],
+ ])(
+ 'when error is: %s, should render button: %s | should render link: %s',
+ async (failureReason, buttonExists, linkExists) => {
+ await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason });
+
+ expect(findRetryButton().exists()).toBe(buttonExists);
+ expect(findRetryLink().exists()).toBe(linkExists);
+ expect(wrapper.text()).toMatch('Retry');
+ },
+ );
+
+ describe('Button', () => {
+ it('should have the correct configuration', async () => {
+ await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure });
+
+ expect(findRetryButton().attributes()).toMatchObject({
+ category: 'primary',
+ variant: 'info',
+ });
+ });
+ });
+
+ describe('Link', () => {
+ it('should have the correct configuration', () => {
+ expect(findRetryLink().attributes()).toMatchObject({
+ 'data-method': 'post',
+ href: job.retry_path,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js
index c2412a807c3..314b23ec29b 100644
--- a/spec/frontend/jobs/components/log/line_spec.js
+++ b/spec/frontend/jobs/components/log/line_spec.js
@@ -2,21 +2,26 @@ import { shallowMount } from '@vue/test-utils';
import Line from '~/jobs/components/log/line.vue';
import LineNumber from '~/jobs/components/log/line_number.vue';
+const httpUrl = 'http://example.com';
+const httpsUrl = 'https://example.com';
+
+const mockProps = ({ text = 'Running with gitlab-runner 12.1.0 (de7731dd)' } = {}) => ({
+ line: {
+ content: [
+ {
+ text,
+ style: 'term-fg-l-green',
+ },
+ ],
+ lineNumber: 0,
+ },
+ path: '/jashkenas/underscore/-/jobs/335',
+});
+
describe('Job Log Line', () => {
let wrapper;
-
- const data = {
- line: {
- content: [
- {
- text: 'Running with gitlab-runner 12.1.0 (de7731dd)',
- style: 'term-fg-l-green',
- },
- ],
- lineNumber: 0,
- },
- path: '/jashkenas/underscore/-/jobs/335',
- };
+ let data;
+ let originalGon;
const createComponent = (props = {}) => {
wrapper = shallowMount(Line, {
@@ -26,12 +31,25 @@ describe('Job Log Line', () => {
});
};
+ const findLine = () => wrapper.find('span');
+ const findLink = () => findLine().find('a');
+ const findLinksAt = i =>
+ findLine()
+ .findAll('a')
+ .at(i);
+
beforeEach(() => {
+ originalGon = window.gon;
+ window.gon.features = {
+ ciJobLineLinks: false,
+ };
+
+ data = mockProps();
createComponent(data);
});
afterEach(() => {
- wrapper.destroy();
+ window.gon = originalGon;
});
it('renders the line number component', () => {
@@ -39,10 +57,109 @@ describe('Job Log Line', () => {
});
it('renders a span the provided text', () => {
- expect(wrapper.find('span').text()).toBe(data.line.content[0].text);
+ expect(findLine().text()).toBe(data.line.content[0].text);
});
it('renders the provided style as a class attribute', () => {
- expect(wrapper.find('span').classes()).toContain(data.line.content[0].style);
+ expect(findLine().classes()).toContain(data.line.content[0].style);
+ });
+
+ describe.each([true, false])('when feature ci_job_line_links enabled = %p', ciJobLineLinks => {
+ beforeEach(() => {
+ window.gon.features = {
+ ciJobLineLinks,
+ };
+ });
+
+ it('renders text with symbols', () => {
+ const text = 'apt-get update < /dev/null > /dev/null';
+ createComponent(mockProps({ text }));
+
+ expect(findLine().text()).toBe(text);
+ });
+
+ it.each`
+ tag | text
+ ${'a'} | ${'<a href="#">linked</a>'}
+ ${'script'} | ${'<script>doEvil();</script>'}
+ ${'strong'} | ${'<strong>highlighted</strong>'}
+ `('escapes `<$tag>` tags in text', ({ tag, text }) => {
+ createComponent(mockProps({ text }));
+
+ expect(
+ findLine()
+ .find(tag)
+ .exists(),
+ ).toBe(false);
+ expect(findLine().text()).toBe(text);
+ });
+ });
+
+ describe('when ci_job_line_links is enabled', () => {
+ beforeEach(() => {
+ window.gon.features = {
+ ciJobLineLinks: true,
+ };
+ });
+
+ it('renders an http link', () => {
+ createComponent(mockProps({ text: httpUrl }));
+
+ expect(findLink().text()).toBe(httpUrl);
+ expect(findLink().attributes().href).toBe(httpUrl);
+ });
+
+ it('renders an https link', () => {
+ createComponent(mockProps({ text: httpsUrl }));
+
+ expect(findLink().text()).toBe(httpsUrl);
+ expect(findLink().attributes().href).toBe(httpsUrl);
+ });
+
+ it('renders a multiple links surrounded by text', () => {
+ createComponent(mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl}` }));
+ expect(findLine().text()).toBe(
+ 'My HTTP url: http://example.com and my HTTPS url: https://example.com',
+ );
+ expect(findLinksAt(0).attributes().href).toBe(httpUrl);
+ expect(findLinksAt(1).attributes().href).toBe(httpsUrl);
+ });
+
+ it('renders a link with rel nofollow and noopener', () => {
+ createComponent(mockProps({ text: httpsUrl }));
+
+ expect(findLink().attributes().rel).toBe('nofollow noopener noreferrer');
+ });
+
+ it('renders a link with corresponding styles', () => {
+ createComponent(mockProps({ text: httpsUrl }));
+
+ expect(findLink().classes()).toEqual(['gl-reset-color!', 'gl-text-decoration-underline']);
+ });
+
+ it('render links surrounded by text', () => {
+ createComponent(
+ mockProps({ text: `My HTTP url: ${httpUrl} and my HTTPS url: ${httpsUrl} are here.` }),
+ );
+ expect(findLine().text()).toBe(
+ 'My HTTP url: http://example.com and my HTTPS url: https://example.com are here.',
+ );
+ expect(findLinksAt(0).attributes().href).toBe(httpUrl);
+ expect(findLinksAt(1).attributes().href).toBe(httpsUrl);
+ });
+
+ const jshref = 'javascript:doEvil();'; // eslint-disable-line no-script-url
+
+ test.each`
+ type | text
+ ${'js'} | ${jshref}
+ ${'file'} | ${'file:///a-file'}
+ ${'ftp'} | ${'ftp://example.com/file'}
+ ${'email'} | ${'email@example.com'}
+ ${'no scheme'} | ${'example.com/page'}
+ `('does not render a $type link', ({ text }) => {
+ createComponent(mockProps({ text }));
+ expect(findLink().exists()).toBe(false);
+ });
});
});
diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js
index 48788df0c93..1d4be2fb81e 100644
--- a/spec/frontend/jobs/components/sidebar_spec.js
+++ b/spec/frontend/jobs/components/sidebar_spec.js
@@ -1,167 +1,166 @@
-import Vue from 'vue';
-import sidebarDetailsBlock from '~/jobs/components/sidebar.vue';
+import { shallowMount } from '@vue/test-utils';
+import Sidebar, { forwardDeploymentFailureModalId } from '~/jobs/components/sidebar.vue';
+import StagesDropdown from '~/jobs/components/stages_dropdown.vue';
+import JobsContainer from '~/jobs/components/jobs_container.vue';
+import JobRetryForwardDeploymentModal from '~/jobs/components/job_retry_forward_deployment_modal.vue';
+import JobRetryButton from '~/jobs/components/job_sidebar_retry_button.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';
+import { extendedWrapper } from '../../helpers/vue_test_utils_helper';
describe('Sidebar details block', () => {
- const SidebarComponent = Vue.extend(sidebarDetailsBlock);
- let vm;
let store;
+ let wrapper;
- beforeEach(() => {
+ const forwardDeploymentFailure = 'forward_deployment_failure';
+ const findModal = () => wrapper.find(JobRetryForwardDeploymentModal);
+ const findCancelButton = () => wrapper.findByTestId('cancel-button');
+ const findNewIssueButton = () => wrapper.findByTestId('job-new-issue');
+ const findRetryButton = () => wrapper.find(JobRetryButton);
+ const findTerminalLink = () => wrapper.findByTestId('terminal-link');
+
+ const createWrapper = ({ props = {} } = {}) => {
store = createStore();
- });
+ wrapper = extendedWrapper(
+ shallowMount(Sidebar, {
+ ...props,
+ store,
+ }),
+ );
+ };
afterEach(() => {
- vm.$destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ wrapper = null;
+ }
});
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,
- });
+ it('should not render a retry button', async () => {
+ createWrapper();
+ const copy = { ...job, retry_path: null };
+ await store.dispatch('receiveJobSuccess', copy);
- expect(vm.$el.querySelector('.js-retry-button')).toBeNull();
+ expect(findRetryButton().exists()).toBe(false);
});
});
describe('without terminal path', () => {
- it('does not render terminal link', () => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
+ it('does not render terminal link', async () => {
+ createWrapper();
+ await store.dispatch('receiveJobSuccess', job);
- expect(vm.$el.querySelector('.js-terminal-link')).toBeNull();
+ expect(findTerminalLink().exists()).toBe(false);
});
});
describe('with terminal path', () => {
- it('renders terminal link', () => {
- store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
- vm = mountComponentWithStore(SidebarComponent, {
- store,
- });
+ it('renders terminal link', async () => {
+ createWrapper();
+ await store.dispatch('receiveJobSuccess', { ...job, terminal_path: 'job/43123/terminal' });
- expect(vm.$el.querySelector('.js-terminal-link')).not.toBeNull();
+ expect(findTerminalLink().exists()).toBe(true);
});
});
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
-
describe('actions', () => {
- it('should render link to new issue', () => {
- expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual(
- job.new_issue_path,
- );
+ beforeEach(() => {
+ createWrapper();
+ return store.dispatch('receiveJobSuccess', job);
+ });
- expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual(
- 'New issue',
- );
+ it('should render link to new issue', () => {
+ expect(findNewIssueButton().attributes('href')).toBe(job.new_issue_path);
+ expect(findNewIssueButton().text()).toBe('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 the retry button', () => {
+ expect(findRetryButton().props('href')).toBe(job.retry_path);
});
it('should render link to cancel job', () => {
- expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
+ expect(findCancelButton().text()).toMatch('Cancel');
+ expect(findCancelButton().attributes('href')).toBe(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',
+ describe('forward deployment failure', () => {
+ describe('when the relevant data is missing', () => {
+ it.each`
+ retryPath | failureReason
+ ${null} | ${null}
+ ${''} | ${''}
+ ${job.retry_path} | ${''}
+ ${''} | ${forwardDeploymentFailure}
+ ${job.retry_path} | ${'unmet_prerequisites'}
+ `(
+ 'should not render the modal when path and failure are $retryPath, $failureReason',
+ async ({ retryPath, failureReason }) => {
+ createWrapper();
+ await store.dispatch('receiveJobSuccess', {
+ ...job,
+ failure_reason: failureReason,
+ retry_path: retryPath,
+ });
+ expect(findModal().exists()).toBe(false);
+ },
);
});
- it('should render runner ID', () => {
- expect(trimText(vm.$el.querySelector('.js-job-runner').textContent)).toEqual(
- 'Runner: local ci runner (#1)',
- );
- });
+ describe('when there is the relevant error', () => {
+ beforeEach(() => {
+ createWrapper();
+ return store.dispatch('receiveJobSuccess', {
+ ...job,
+ failure_reason: forwardDeploymentFailure,
+ });
+ });
- it('should render timeout information', () => {
- expect(trimText(vm.$el.querySelector('.js-job-timeout').textContent)).toEqual(
- 'Timeout: 1m 40s (from runner)',
- );
- });
+ it('should render the modal', () => {
+ expect(findModal().exists()).toBe(true);
+ });
- it('should render coverage', () => {
- expect(trimText(vm.$el.querySelector('.js-job-coverage').textContent)).toEqual(
- 'Coverage: 20%',
- );
- });
+ it('should provide the modal id to the button and modal', () => {
+ expect(findRetryButton().props('modalId')).toBe(forwardDeploymentFailureModalId);
+ expect(findModal().props('modalId')).toBe(forwardDeploymentFailureModalId);
+ });
- it('should render tags', () => {
- expect(trimText(vm.$el.querySelector('.js-job-tags').textContent)).toEqual('Tags: tag');
+ it('should provide the retry path to the button and modal', () => {
+ expect(findRetryButton().props('href')).toBe(job.retry_path);
+ expect(findModal().props('href')).toBe(job.retry_path);
+ });
});
});
describe('stages dropdown', () => {
beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
+ createWrapper();
+ return store.dispatch('receiveJobSuccess', { ...job, stage: 'aStage' });
});
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,
- );
+ expect(wrapper.find(StagesDropdown).props('selectedStage')).toBe('aStage');
});
});
describe('without jobs for stages', () => {
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- vm = mountComponentWithStore(SidebarComponent, { store });
- });
+ beforeEach(() => store.dispatch('receiveJobSuccess', job));
- it('does not render job container', () => {
- expect(vm.$el.querySelector('.js-jobs-container')).toBeNull();
+ it('does not render jobs container', () => {
+ expect(wrapper.find(JobsContainer).exists()).toBe(false);
});
});
describe('with jobs for stages', () => {
- beforeEach(() => {
- store.dispatch('receiveJobSuccess', job);
- store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
- vm = mountComponentWithStore(SidebarComponent, { store });
+ beforeEach(async () => {
+ await store.dispatch('receiveJobSuccess', job);
+ await store.dispatch('receiveJobsForStageSuccess', jobsInStage.latest_statuses);
});
it('renders list of jobs', () => {
- expect(vm.$el.querySelector('.js-jobs-container')).not.toBeNull();
+ expect(wrapper.find(JobsContainer).exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/lib/utils/apollo_startup_js_link_spec.js b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js
new file mode 100644
index 00000000000..faead3ff8fe
--- /dev/null
+++ b/spec/frontend/lib/utils/apollo_startup_js_link_spec.js
@@ -0,0 +1,375 @@
+import { ApolloLink, Observable } from 'apollo-link';
+import { StartupJSLink } from '~/lib/utils/apollo_startup_js_link';
+
+describe('StartupJSLink', () => {
+ const FORWARDED_RESPONSE = { data: 'FORWARDED_RESPONSE' };
+
+ const STARTUP_JS_RESPONSE = { data: 'STARTUP_JS_RESPONSE' };
+ const OPERATION_NAME = 'startupJSQuery';
+ const STARTUP_JS_QUERY = `query ${OPERATION_NAME}($id: Int = 3){
+ name
+ id
+ }`;
+
+ const STARTUP_JS_RESPONSE_TWO = { data: 'STARTUP_JS_RESPONSE_TWO' };
+ const OPERATION_NAME_TWO = 'startupJSQueryTwo';
+ const STARTUP_JS_QUERY_TWO = `query ${OPERATION_NAME_TWO}($id: Int = 3){
+ id
+ name
+ }`;
+
+ const ERROR_RESPONSE = {
+ data: {
+ user: null,
+ },
+ errors: [
+ {
+ path: ['user'],
+ locations: [{ line: 2, column: 3 }],
+ extensions: {
+ message: 'Object not found',
+ type: 2,
+ },
+ },
+ ],
+ };
+
+ let startupLink;
+ let link;
+
+ function mockFetchCall(status = 200, response = STARTUP_JS_RESPONSE) {
+ const p = {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: new Headers({ 'Content-Type': 'application/json' }),
+ statusText: `MOCK-FETCH ${status}`,
+ clone: () => p,
+ json: () => Promise.resolve(response),
+ };
+ return Promise.resolve(p);
+ }
+
+ function mockOperation({ operationName = OPERATION_NAME, variables = { id: 3 } } = {}) {
+ return { operationName, variables, setContext: () => {} };
+ }
+
+ const setupLink = () => {
+ startupLink = new StartupJSLink();
+ link = ApolloLink.from([startupLink, new ApolloLink(() => Observable.of(FORWARDED_RESPONSE))]);
+ };
+
+ it('forwards requests if no calls are set up', done => {
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls).toBe(null);
+ expect(startupLink.request).toEqual(StartupJSLink.noopRequest);
+ done();
+ });
+ });
+
+ it('forwards requests if the operation is not pre-loaded', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ operationName: 'notLoaded' })).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(1);
+ done();
+ });
+ });
+
+ describe('variable match errors: ', () => {
+ it('forwards requests if the variables are not matching', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 'NOT_MATCHING' },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards requests if more variables are set in the operation', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards requests if less variables are set in the operation', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3, name: 'tanuki' },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards requests if different variables are set', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { name: 'tanuki' },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ variables: { id: 3 } })).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards requests if array variables have a different order', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: [3, 4] },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ variables: { id: [4, 3] } })).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+ });
+
+ describe('error handling', () => {
+ it('forwards the call if the fetchCall is failing with a HTTP Error', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(404),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards the call if it errors (e.g. failing JSON)', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: Promise.reject(new Error('Parsing failed')),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('forwards the call if the response contains an error', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(200, ERROR_RESPONSE),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it("forwards the call if the response doesn't contain a data object", done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(200, { 'no-data': 'yay' }),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(FORWARDED_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+ });
+
+ it('resolves the request if the operation is matching', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('resolves the request exactly once', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation()).subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ link.request(mockOperation()).subscribe(result2 => {
+ expect(result2).toEqual(FORWARDED_RESPONSE);
+ done();
+ });
+ });
+ });
+
+ it('resolves the request if the variables have a different order', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3, name: 'foo' },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ variables: { name: 'foo', id: 3 } })).subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('resolves the request if the variables have undefined values', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { name: 'foo' },
+ },
+ ],
+ };
+ setupLink();
+ link
+ .request(mockOperation({ variables: { name: 'foo', undef: undefined } }))
+ .subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('resolves the request if the variables are of an array format', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: [3, 4] },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ variables: { id: [3, 4] } })).subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+
+ it('resolves multiple requests correctly', done => {
+ window.gl = {
+ startup_graphql_calls: [
+ {
+ fetchCall: mockFetchCall(),
+ query: STARTUP_JS_QUERY,
+ variables: { id: 3 },
+ },
+ {
+ fetchCall: mockFetchCall(200, STARTUP_JS_RESPONSE_TWO),
+ query: STARTUP_JS_QUERY_TWO,
+ variables: { id: 3 },
+ },
+ ],
+ };
+ setupLink();
+ link.request(mockOperation({ operationName: OPERATION_NAME_TWO })).subscribe(result => {
+ expect(result).toEqual(STARTUP_JS_RESPONSE_TWO);
+ expect(startupLink.startupCalls.size).toBe(1);
+ link.request(mockOperation({ operationName: OPERATION_NAME })).subscribe(result2 => {
+ expect(result2).toEqual(STARTUP_JS_RESPONSE);
+ expect(startupLink.startupCalls.size).toBe(0);
+ done();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index effc446d846..09eb362c77e 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -959,6 +959,25 @@ describe('common_utils', () => {
});
});
+ describe('roundDownFloat', () => {
+ it('Rounds down decimal places of a float number with provided precision', () => {
+ expect(commonUtils.roundDownFloat(3.141592, 3)).toBe(3.141);
+ });
+
+ it('Rounds down a float number to a whole number when provided precision is zero', () => {
+ expect(commonUtils.roundDownFloat(3.141592, 0)).toBe(3);
+ expect(commonUtils.roundDownFloat(3.9, 0)).toBe(3);
+ });
+
+ it('Rounds down float number to nearest 0, 10, 100, 1000 and so on when provided precision is below 0', () => {
+ expect(commonUtils.roundDownFloat(34567.14159, -1)).toBeCloseTo(34560);
+ expect(commonUtils.roundDownFloat(34567.14159, -2)).toBeCloseTo(34500);
+ expect(commonUtils.roundDownFloat(34567.14159, -3)).toBeCloseTo(34000);
+ expect(commonUtils.roundDownFloat(34567.14159, -4)).toBeCloseTo(30000);
+ expect(commonUtils.roundDownFloat(34567.14159, -5)).toBeCloseTo(0);
+ });
+ });
+
describe('searchBy', () => {
const searchSpace = {
iid: 1,
diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js
index b0b0b028761..6092b44720f 100644
--- a/spec/frontend/lib/utils/datetime_utility_spec.js
+++ b/spec/frontend/lib/utils/datetime_utility_spec.js
@@ -643,16 +643,15 @@ describe('localTimeAgo', () => {
});
it.each`
- timeagoArg | title | dataOriginalTitle
- ${false} | ${'some time'} | ${null}
- ${true} | ${''} | ${'Feb 18, 2020 10:22pm GMT+0000'}
- `('converts $seconds seconds to $approximation', ({ timeagoArg, title, dataOriginalTitle }) => {
+ timeagoArg | title
+ ${false} | ${'some time'}
+ ${true} | ${'Feb 18, 2020 10:22pm GMT+0000'}
+ `('converts $seconds seconds to $approximation', ({ timeagoArg, title }) => {
const element = document.querySelector('time');
datetimeUtility.localTimeAgo($(element), timeagoArg);
jest.runAllTimers();
- expect(element.getAttribute('data-original-title')).toBe(dataOriginalTitle);
expect(element.getAttribute('title')).toBe(title);
});
});
diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js
index d918016a5f4..f5c2a797df5 100644
--- a/spec/frontend/lib/utils/dom_utils_spec.js
+++ b/spec/frontend/lib/utils/dom_utils_spec.js
@@ -3,6 +3,8 @@ import {
canScrollUp,
canScrollDown,
parseBooleanDataAttributes,
+ isElementVisible,
+ isElementHidden,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
@@ -160,4 +162,35 @@ describe('DOM Utils', () => {
});
});
});
+
+ describe.each`
+ offsetWidth | offsetHeight | clientRectsLength | visible
+ ${0} | ${0} | ${0} | ${false}
+ ${1} | ${0} | ${0} | ${true}
+ ${0} | ${1} | ${0} | ${true}
+ ${0} | ${0} | ${1} | ${true}
+ `(
+ 'isElementVisible and isElementHidden',
+ ({ offsetWidth, offsetHeight, clientRectsLength, visible }) => {
+ const element = {
+ offsetWidth,
+ offsetHeight,
+ getClientRects: () => new Array(clientRectsLength),
+ };
+
+ const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`;
+
+ describe('isElementVisible', () => {
+ it(`returns ${visible} when ${paramDescription}`, () => {
+ expect(isElementVisible(element)).toBe(visible);
+ });
+ });
+
+ describe('isElementHidden', () => {
+ it(`returns ${!visible} when ${paramDescription}`, () => {
+ expect(isElementHidden(element)).toBe(!visible);
+ });
+ });
+ },
+ );
});
diff --git a/spec/frontend/lib/utils/number_utility_spec.js b/spec/frontend/lib/utils/number_utility_spec.js
index f600f2bcd55..2f8f1092612 100644
--- a/spec/frontend/lib/utils/number_utility_spec.js
+++ b/spec/frontend/lib/utils/number_utility_spec.js
@@ -1,6 +1,5 @@
import {
formatRelevantDigits,
- bytesToKB,
bytesToKiB,
bytesToMiB,
bytesToGiB,
@@ -55,16 +54,6 @@ describe('Number Utils', () => {
});
});
- describe('bytesToKB', () => {
- it.each`
- input | output
- ${1000} | ${1}
- ${1024} | ${1.024}
- `('returns $output KB for $input bytes', ({ input, output }) => {
- expect(bytesToKB(input)).toBe(output);
- });
- });
-
describe('bytesToKiB', () => {
it('calculates KiB for the given bytes', () => {
expect(bytesToKiB(1024)).toEqual(1);
diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js
index 6fef5f6b63c..d7cedb939d2 100644
--- a/spec/frontend/lib/utils/text_utility_spec.js
+++ b/spec/frontend/lib/utils/text_utility_spec.js
@@ -325,4 +325,19 @@ describe('text_utility', () => {
expect(textUtils.hasContent(txt)).toEqual(result);
});
});
+
+ describe('isValidSha1Hash', () => {
+ const validSha1Hash = '92d10c15';
+ const stringOver40 = new Array(42).join('a');
+
+ it.each`
+ hash | valid
+ ${validSha1Hash} | ${true}
+ ${'__characters'} | ${false}
+ ${'abc'} | ${false}
+ ${stringOver40} | ${false}
+ `(`returns $valid for $hash`, ({ hash, valid }) => {
+ expect(textUtils.isValidSha1Hash(hash)).toBe(valid);
+ });
+ });
});
diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/milestone_combobox_spec.js
new file mode 100644
index 00000000000..047484f117f
--- /dev/null
+++ b/spec/frontend/milestones/milestone_combobox_spec.js
@@ -0,0 +1,518 @@
+import Vuex from 'vuex';
+import { mount, createLocalVue } from '@vue/test-utils';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { ENTER_KEY } from '~/lib/utils/keys';
+import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
+import { projectMilestones, groupMilestones } from './mock_data';
+import createStore from '~/milestones/stores/';
+
+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 localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Milestone combobox component', () => {
+ const projectId = '8';
+ const groupId = '24';
+ const groupMilestonesAvailable = true;
+ const X_TOTAL_HEADER = 'x-total';
+
+ let wrapper;
+ let projectMilestonesApiCallSpy;
+ let groupMilestonesApiCallSpy;
+ let searchApiCallSpy;
+
+ const createComponent = (props = {}, attrs = {}) => {
+ wrapper = mount(MilestoneCombobox, {
+ propsData: {
+ projectId,
+ groupId,
+ groupMilestonesAvailable,
+ extraLinks,
+ value: [],
+ ...props,
+ },
+ attrs,
+ listeners: {
+ // simulate a parent component v-model binding
+ input: selectedMilestone => {
+ wrapper.setProps({ value: selectedMilestone });
+ },
+ },
+ stubs: {
+ GlSearchBoxByType: true,
+ },
+ localVue,
+ store: createStore(),
+ });
+ };
+
+ beforeEach(() => {
+ const mock = new MockAdapter(axios);
+ gon.api_version = 'v4';
+
+ projectMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
+
+ groupMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, groupMilestones, { [X_TOTAL_HEADER]: '6' }]);
+
+ searchApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
+
+ mock
+ .onGet(`/api/v4/projects/${projectId}/milestones`)
+ .reply(config => projectMilestonesApiCallSpy(config));
+
+ mock
+ .onGet(`/api/v4/groups/${groupId}/milestones`)
+ .reply(config => groupMilestonesApiCallSpy(config));
+
+ mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config));
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ //
+ // Finders
+ //
+ const findButtonContent = () => wrapper.find('[data-testid="milestone-combobox-button-content"]');
+
+ const findNoResults = () => wrapper.find('[data-testid="milestone-combobox-no-results"]');
+
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ const findSearchBox = () => wrapper.find(GlSearchBoxByType);
+
+ const findProjectMilestonesSection = () =>
+ wrapper.find('[data-testid="project-milestones-section"]');
+ const findProjectMilestonesDropdownItems = () =>
+ findProjectMilestonesSection().findAll(GlDropdownItem);
+ const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0);
+
+ const findGroupMilestonesSection = () => wrapper.find('[data-testid="group-milestones-section"]');
+ const findGroupMilestonesDropdownItems = () =>
+ findGroupMilestonesSection().findAll(GlDropdownItem);
+ const findFirstGroupMilestonesDropdownItem = () => findGroupMilestonesDropdownItems().at(0);
+
+ //
+ // Expecters
+ //
+ const projectMilestoneSectionContainsErrorMessage = () => {
+ const projectMilestoneSection = findProjectMilestonesSection();
+
+ return projectMilestoneSection
+ .text()
+ .includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
+ };
+
+ const groupMilestoneSectionContainsErrorMessage = () => {
+ const groupMilestoneSection = findGroupMilestonesSection();
+
+ return groupMilestoneSection
+ .text()
+ .includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
+ };
+
+ //
+ // Convenience methods
+ //
+ const updateQuery = newQuery => {
+ findSearchBox().vm.$emit('input', newQuery);
+ };
+
+ const selectFirstProjectMilestone = () => {
+ findFirstProjectMilestonesDropdownItem().vm.$emit('click');
+ };
+
+ const selectFirstGroupMilestone = () => {
+ findFirstGroupMilestonesDropdownItem().vm.$emit('click');
+ };
+
+ const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) =>
+ axios.waitForAll().then(() => {
+ if (andClearMocks) {
+ projectMilestonesApiCallSpy.mockClear();
+ groupMilestonesApiCallSpy.mockClear();
+ }
+ });
+
+ describe('initialization behavior', () => {
+ beforeEach(createComponent);
+
+ it('initializes the dropdown with milestones when mounted', () => {
+ return waitForRequests().then(() => {
+ expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
+ expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it('shows a spinner while network requests are in progress', () => {
+ expect(findLoadingIcon().exists()).toBe(true);
+
+ return waitForRequests().then(() => {
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ it('shows additional links', () => {
+ const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]');
+ links.wrappers.forEach((item, idx) => {
+ expect(item.text()).toBe(extraLinks[idx].text);
+ expect(item.attributes('href')).toBe(extraLinks[idx].url);
+ });
+ });
+ });
+
+ describe('post-initialization behavior', () => {
+ describe('when the parent component provides an `id` binding', () => {
+ const id = '8';
+
+ beforeEach(() => {
+ createComponent({}, { id });
+
+ return waitForRequests();
+ });
+
+ it('adds the provided ID to the GlDropdown instance', () => {
+ expect(wrapper.attributes().id).toBe(id);
+ });
+ });
+
+ describe('when milestones are pre-selected', () => {
+ beforeEach(() => {
+ createComponent({ value: projectMilestones });
+
+ return waitForRequests();
+ });
+
+ it('renders the pre-selected milestones', () => {
+ expect(findButtonContent().text()).toBe('v0.1 + 5 more');
+ });
+ });
+
+ describe('when the search query is updated', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests({ andClearMocks: true });
+ });
+
+ it('requeries the search when the search query is updated', () => {
+ updateQuery('v1.2.3');
+
+ return waitForRequests().then(() => {
+ expect(searchApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('when the Enter is pressed', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests({ andClearMocks: true });
+ });
+
+ it('requeries the search when Enter is pressed', () => {
+ findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
+
+ return waitForRequests().then(() => {
+ expect(searchApiCallSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ describe('when no results are found', () => {
+ beforeEach(() => {
+ projectMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ groupMilestonesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ describe('when the search query is empty', () => {
+ it('renders a "no results" message', () => {
+ expect(findNoResults().text()).toBe(s__('MilestoneCombobox|No matching results'));
+ });
+ });
+ });
+
+ describe('project milestones', () => {
+ describe('when the project milestones search returns results', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the project milestones section in the dropdown', () => {
+ expect(findProjectMilestonesSection().exists()).toBe(true);
+ });
+
+ it('renders the "Project milestones" heading with a total number indicator', () => {
+ expect(
+ findProjectMilestonesSection()
+ .find('[data-testid="milestone-results-section-header"]')
+ .text(),
+ ).toBe('Project milestones 6');
+ });
+
+ it("does not render an error message in the project milestone section's body", () => {
+ expect(projectMilestoneSectionContainsErrorMessage()).toBe(false);
+ });
+
+ it('renders each project milestones as a selectable item', () => {
+ const dropdownItems = findProjectMilestonesDropdownItems();
+
+ projectMilestones.forEach((milestone, i) => {
+ expect(dropdownItems.at(i).text()).toBe(milestone.title);
+ });
+ });
+ });
+
+ describe('when the project milestones search returns no results', () => {
+ beforeEach(() => {
+ projectMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('does not render the project milestones section in the dropdown', () => {
+ expect(findProjectMilestonesSection().exists()).toBe(false);
+ });
+ });
+
+ describe('when the project milestones search returns an error', () => {
+ beforeEach(() => {
+ projectMilestonesApiCallSpy = jest.fn().mockReturnValue([500]);
+ searchApiCallSpy = jest.fn().mockReturnValue([500]);
+
+ createComponent({ value: [] });
+
+ return waitForRequests();
+ });
+
+ it('renders the project milestones section in the dropdown', () => {
+ expect(findProjectMilestonesSection().exists()).toBe(true);
+ });
+
+ it("renders an error message in the project milestones section's body", () => {
+ expect(projectMilestoneSectionContainsErrorMessage()).toBe(true);
+ });
+ });
+
+ describe('selection', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders a checkmark by the selected item', async () => {
+ selectFirstProjectMilestone();
+
+ await localVue.nextTick();
+
+ expect(
+ findFirstProjectMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(false);
+
+ selectFirstProjectMilestone();
+
+ await localVue.nextTick();
+
+ expect(
+ findFirstProjectMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(true);
+ });
+
+ describe('when a project milestones is selected', () => {
+ beforeEach(() => {
+ createComponent();
+ projectMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
+
+ return waitForRequests();
+ });
+
+ it("displays the project milestones name in the dropdown's button", async () => {
+ selectFirstProjectMilestone();
+ await localVue.nextTick();
+
+ expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
+
+ selectFirstProjectMilestone();
+
+ await localVue.nextTick();
+ expect(findButtonContent().text()).toBe('v1.0');
+ });
+
+ it('updates the v-model binding with the project milestone title', () => {
+ expect(wrapper.vm.value).toEqual([]);
+
+ selectFirstProjectMilestone();
+
+ expect(wrapper.vm.value).toEqual(['v1.0']);
+ });
+ });
+ });
+ });
+
+ describe('group milestones', () => {
+ describe('when the group milestones search returns results', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders the group milestones section in the dropdown', () => {
+ expect(findGroupMilestonesSection().exists()).toBe(true);
+ });
+
+ it('renders the "Group milestones" heading with a total number indicator', () => {
+ expect(
+ findGroupMilestonesSection()
+ .find('[data-testid="milestone-results-section-header"]')
+ .text(),
+ ).toBe('Group milestones 6');
+ });
+
+ it("does not render an error message in the group milestone section's body", () => {
+ expect(groupMilestoneSectionContainsErrorMessage()).toBe(false);
+ });
+
+ it('renders each group milestones as a selectable item', () => {
+ const dropdownItems = findGroupMilestonesDropdownItems();
+
+ groupMilestones.forEach((milestone, i) => {
+ expect(dropdownItems.at(i).text()).toBe(milestone.title);
+ });
+ });
+ });
+
+ describe('when the group milestones search returns no results', () => {
+ beforeEach(() => {
+ groupMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('does not render the group milestones section in the dropdown', () => {
+ expect(findGroupMilestonesSection().exists()).toBe(false);
+ });
+ });
+
+ describe('when the group milestones search returns an error', () => {
+ beforeEach(() => {
+ groupMilestonesApiCallSpy = jest.fn().mockReturnValue([500]);
+ searchApiCallSpy = jest.fn().mockReturnValue([500]);
+
+ createComponent({ value: [] });
+
+ return waitForRequests();
+ });
+
+ it('renders the group milestones section in the dropdown', () => {
+ expect(findGroupMilestonesSection().exists()).toBe(true);
+ });
+
+ it("renders an error message in the group milestones section's body", () => {
+ expect(groupMilestoneSectionContainsErrorMessage()).toBe(true);
+ });
+ });
+
+ describe('selection', () => {
+ beforeEach(() => {
+ createComponent();
+
+ return waitForRequests();
+ });
+
+ it('renders a checkmark by the selected item', async () => {
+ selectFirstGroupMilestone();
+
+ await localVue.nextTick();
+
+ expect(
+ findFirstGroupMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(false);
+
+ selectFirstGroupMilestone();
+
+ await localVue.nextTick();
+
+ expect(
+ findFirstGroupMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(true);
+ });
+
+ describe('when a group milestones is selected', () => {
+ beforeEach(() => {
+ createComponent();
+ groupMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [{ title: 'group-v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
+
+ return waitForRequests();
+ });
+
+ it("displays the group milestones name in the dropdown's button", async () => {
+ selectFirstGroupMilestone();
+ await localVue.nextTick();
+
+ expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
+
+ selectFirstGroupMilestone();
+
+ await localVue.nextTick();
+ expect(findButtonContent().text()).toBe('group-v1.0');
+ });
+
+ it('updates the v-model binding with the group milestone title', () => {
+ expect(wrapper.vm.value).toEqual([]);
+
+ selectFirstGroupMilestone();
+
+ expect(wrapper.vm.value).toEqual(['group-v1.0']);
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js
index c64eeeba663..71fbfe54141 100644
--- a/spec/frontend/milestones/mock_data.js
+++ b/spec/frontend/milestones/mock_data.js
@@ -1,4 +1,4 @@
-export const milestones = [
+export const projectMilestones = [
{
id: 41,
iid: 6,
@@ -79,4 +79,94 @@ export const milestones = [
},
];
-export default milestones;
+export const groupMilestones = [
+ {
+ id: 141,
+ iid: 16,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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: 140,
+ iid: 15,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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: 139,
+ iid: 14,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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: 138,
+ iid: 13,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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: 137,
+ iid: 12,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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: 136,
+ iid: 11,
+ project_id: 8,
+ group_id: 12,
+ title: 'group-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 {
+ projectMilestones,
+ groupMilestones,
+};
diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js
deleted file mode 100644
index 60d68aa5816..00000000000
--- a/spec/frontend/milestones/project_milestone_combobox_spec.js
+++ /dev/null
@@ -1,186 +0,0 @@
-import axios from 'axios';
-import MockAdapter from 'axios-mock-adapter';
-import { shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
-import { ENTER_KEY } from '~/lib/utils/keys';
-import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue';
-import { milestones as projectMilestones } from './mock_data';
-
-const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search';
-const TEST_SEARCH = 'TEST_SEARCH';
-
-const extraLinks = [
- { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' },
- { 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 findSearchBox = () => wrapper.find(GlSearchBoxByType);
-
- 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(GlDropdown)).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, []);
- findSearchBox().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',
- },
- ]);
- findSearchBox().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);
- });
- });
-
- describe('when Enter is pressed', () => {
- beforeEach(() => {
- factory({
- propsData: {
- projectId,
- preselectedMilestones,
- extraLinks,
- },
- data() {
- return {
- searchQuery: 'TEST_SEARCH',
- };
- },
- });
-
- mock
- .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } })
- .reply(200, []);
- });
-
- it('should trigger a search', async () => {
- mock.resetHistory();
-
- findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
-
- await axios.waitForAll();
-
- expect(mock.history.get.length).toBe(1);
- expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT);
- });
- });
-});
diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js
index ad73d0e4238..a62b0c49a80 100644
--- a/spec/frontend/milestones/stores/actions_spec.js
+++ b/spec/frontend/milestones/stores/actions_spec.js
@@ -4,6 +4,7 @@ import * as actions from '~/milestones/stores/actions';
import * as types from '~/milestones/stores/mutation_types';
let mockProjectMilestonesReturnValue;
+let mockGroupMilestonesReturnValue;
let mockProjectSearchReturnValue;
jest.mock('~/api', () => ({
@@ -13,6 +14,7 @@ jest.mock('~/api', () => ({
default: {
projectMilestones: () => mockProjectMilestonesReturnValue,
projectSearch: () => mockProjectSearchReturnValue,
+ groupMilestones: () => mockGroupMilestonesReturnValue,
},
}));
@@ -32,6 +34,24 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
+ describe('setGroupId', () => {
+ it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => {
+ const groupId = '123';
+ testAction(actions.setGroupId, groupId, state, [
+ { type: types.SET_GROUP_ID, payload: groupId },
+ ]);
+ });
+ });
+
+ describe('setGroupMilestonesAvailable', () => {
+ it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => {
+ state.groupMilestonesAvailable = true;
+ testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [
+ { type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable },
+ ]);
+ });
+ });
+
describe('setSelectedMilestones', () => {
it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => {
const selectedMilestones = ['v1.2.3'];
@@ -41,6 +61,14 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
+ describe('clearSelectedMilestones', () => {
+ it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => {
+ testAction(actions.clearSelectedMilestones, null, state, [
+ { type: types.CLEAR_SELECTED_MILESTONES },
+ ]);
+ });
+ });
+
describe('toggleMilestones', () => {
const selectedMilestone = 'v1.2.3';
it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => {
@@ -58,19 +86,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
describe('search', () => {
- it(`commits ${types.SET_QUERY} with the new search query`, () => {
- const query = 'v1.0';
- testAction(
- actions.search,
- query,
- state,
- [{ type: types.SET_QUERY, payload: query }],
- [{ type: 'searchMilestones' }],
- );
+ describe('when project has license to add group milestones', () => {
+ it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project and group milestones`, () => {
+ const getters = {
+ groupMilestonesEnabled: () => true,
+ };
+
+ const searchQuery = 'v1.0';
+ testAction(
+ actions.search,
+ searchQuery,
+ { ...state, ...getters },
+ [{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
+ [{ type: 'searchProjectMilestones' }, { type: 'searchGroupMilestones' }],
+ );
+ });
+ });
+
+ describe('when project does not have license to add group milestones', () => {
+ it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => {
+ const searchQuery = 'v1.0';
+ testAction(
+ actions.search,
+ searchQuery,
+ state,
+ [{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
+ [{ type: 'searchProjectMilestones' }],
+ );
+ });
});
});
- describe('searchMilestones', () => {
+ describe('searchProjectMilestones', () => {
describe('when the search is successful', () => {
const projectSearchApiResponse = { data: [{ title: 'v1.0' }] };
@@ -79,7 +126,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
- return testAction(actions.searchMilestones, undefined, state, [
+ return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectSearchApiResponse },
{ type: types.REQUEST_FINISH },
@@ -95,7 +142,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
- return testAction(actions.searchMilestones, undefined, state, [
+ return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
@@ -104,7 +151,71 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
+ describe('searchGroupMilestones', () => {
+ describe('when the search is successful', () => {
+ const groupSearchApiResponse = { data: [{ title: 'group-v1.0' }] };
+
+ beforeEach(() => {
+ mockGroupMilestonesReturnValue = Promise.resolve(groupSearchApiResponse);
+ });
+
+ it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
+ return testAction(actions.searchGroupMilestones, undefined, state, [
+ { type: types.REQUEST_START },
+ { type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupSearchApiResponse },
+ { type: types.REQUEST_FINISH },
+ ]);
+ });
+ });
+
+ describe('when the search fails', () => {
+ const error = new Error('Something went wrong!');
+
+ beforeEach(() => {
+ mockGroupMilestonesReturnValue = Promise.reject(error);
+ });
+
+ it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
+ return testAction(actions.searchGroupMilestones, undefined, state, [
+ { type: types.REQUEST_START },
+ { type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
+ { type: types.REQUEST_FINISH },
+ ]);
+ });
+ });
+ });
+
describe('fetchMilestones', () => {
+ describe('when project has license to add group milestones', () => {
+ it(`dispatchs fetchProjectMilestones and fetchGroupMilestones`, () => {
+ const getters = {
+ groupMilestonesEnabled: () => true,
+ };
+
+ testAction(
+ actions.fetchMilestones,
+ undefined,
+ { ...state, ...getters },
+ [],
+ [{ type: 'fetchProjectMilestones' }, { type: 'fetchGroupMilestones' }],
+ );
+ });
+ });
+
+ describe('when project does not have license to add group milestones', () => {
+ it(`dispatchs fetchProjectMilestones`, () => {
+ testAction(
+ actions.fetchMilestones,
+ undefined,
+ state,
+ [],
+ [{ type: 'fetchProjectMilestones' }],
+ );
+ });
+ });
+ });
+
+ describe('fetchProjectMilestones', () => {
describe('when the fetch is successful', () => {
const projectMilestonesApiResponse = { data: [{ title: 'v1.0' }] };
@@ -113,7 +224,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
- return testAction(actions.fetchMilestones, undefined, state, [
+ return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectMilestonesApiResponse },
{ type: types.REQUEST_FINISH },
@@ -129,7 +240,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
- return testAction(actions.fetchMilestones, undefined, state, [
+ return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
@@ -137,4 +248,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
});
+
+ describe('fetchGroupMilestones', () => {
+ describe('when the fetch is successful', () => {
+ const groupMilestonesApiResponse = { data: [{ title: 'group-v1.0' }] };
+
+ beforeEach(() => {
+ mockGroupMilestonesReturnValue = Promise.resolve(groupMilestonesApiResponse);
+ });
+
+ it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
+ return testAction(actions.fetchGroupMilestones, undefined, state, [
+ { type: types.REQUEST_START },
+ { type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupMilestonesApiResponse },
+ { type: types.REQUEST_FINISH },
+ ]);
+ });
+ });
+
+ describe('when the fetch fails', () => {
+ const error = new Error('Something went wrong!');
+
+ beforeEach(() => {
+ mockGroupMilestonesReturnValue = Promise.reject(error);
+ });
+
+ it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
+ return testAction(actions.fetchGroupMilestones, undefined, state, [
+ { type: types.REQUEST_START },
+ { type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
+ { type: types.REQUEST_FINISH },
+ ]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/milestones/stores/getter_spec.js b/spec/frontend/milestones/stores/getter_spec.js
index df7c3d28e67..4a6116b642c 100644
--- a/spec/frontend/milestones/stores/getter_spec.js
+++ b/spec/frontend/milestones/stores/getter_spec.js
@@ -12,4 +12,22 @@ describe('Milestone comboxbox Vuex store getters', () => {
expect(getters.isLoading({ requestCount })).toBe(isLoading);
});
});
+
+ describe('groupMilestonesEnabled', () => {
+ it.each`
+ groupId | groupMilestonesAvailable | groupMilestonesEnabled
+ ${'1'} | ${true} | ${true}
+ ${'1'} | ${false} | ${false}
+ ${''} | ${true} | ${false}
+ ${''} | ${false} | ${false}
+ ${null} | ${true} | ${false}
+ `(
+ 'returns true when groupId is a truthy string and groupMilestonesAvailable is true',
+ ({ groupId, groupMilestonesAvailable, groupMilestonesEnabled }) => {
+ expect(getters.groupMilestonesEnabled({ groupId, groupMilestonesAvailable })).toBe(
+ groupMilestonesEnabled,
+ );
+ },
+ );
+ });
});
diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js
index 8f8ce3c87ad..0b69a9d572d 100644
--- a/spec/frontend/milestones/stores/mutations_spec.js
+++ b/spec/frontend/milestones/stores/mutations_spec.js
@@ -14,13 +14,19 @@ describe('Milestones combobox Vuex store mutations', () => {
expect(state).toEqual({
projectId: null,
groupId: null,
- query: '',
+ groupMilestonesAvailable: false,
+ searchQuery: '',
matches: {
projectMilestones: {
list: [],
totalCount: 0,
error: null,
},
+ groupMilestones: {
+ list: [],
+ totalCount: 0,
+ error: null,
+ },
},
selectedMilestones: [],
requestCount: 0,
@@ -37,6 +43,24 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
+ describe(`${types.SET_GROUP_ID}`, () => {
+ it('updates the group ID', () => {
+ const newGroupId = '8';
+ mutations[types.SET_GROUP_ID](state, newGroupId);
+
+ expect(state.groupId).toBe(newGroupId);
+ });
+ });
+
+ describe(`${types.SET_GROUP_MILESTONES_AVAILABLE}`, () => {
+ it('sets boolean indicating if group milestones are available', () => {
+ const groupMilestonesAvailable = true;
+ mutations[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable);
+
+ expect(state.groupMilestonesAvailable).toBe(groupMilestonesAvailable);
+ });
+ });
+
describe(`${types.SET_SELECTED_MILESTONES}`, () => {
it('sets the selected milestones', () => {
const selectedMilestones = ['v1.2.3'];
@@ -46,7 +70,21 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
- describe(`${types.ADD_SELECTED_MILESTONESs}`, () => {
+ describe(`${types.CLEAR_SELECTED_MILESTONES}`, () => {
+ it('clears the selected milestones', () => {
+ const selectedMilestones = ['v1.2.3'];
+
+ // Set state.selectedMilestones
+ mutations[types.SET_SELECTED_MILESTONES](state, selectedMilestones);
+
+ // Clear state.selectedMilestones
+ mutations[types.CLEAR_SELECTED_MILESTONES](state);
+
+ expect(state.selectedMilestones).toEqual([]);
+ });
+ });
+
+ describe(`${types.ADD_SELECTED_MILESTONES}`, () => {
it('adds the selected milestones', () => {
const selectedMilestone = 'v1.2.3';
mutations[types.ADD_SELECTED_MILESTONE](state, selectedMilestone);
@@ -67,12 +105,12 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
- describe(`${types.SET_QUERY}`, () => {
+ describe(`${types.SET_SEARCH_QUERY}`, () => {
it('updates the search query', () => {
const newQuery = 'hello';
- mutations[types.SET_QUERY](state, newQuery);
+ mutations[types.SET_SEARCH_QUERY](state, newQuery);
- expect(state.query).toBe(newQuery);
+ expect(state.searchQuery).toBe(newQuery);
});
});
@@ -156,4 +194,57 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
});
+
+ describe(`${types.RECEIVE_GROUP_MILESTONES_SUCCESS}`, () => {
+ it('updates state.matches.groupMilestones based on the provided API response', () => {
+ const response = {
+ data: [
+ {
+ title: 'group-0.1',
+ },
+ {
+ title: 'group-0.2',
+ },
+ ],
+ headers: {
+ 'x-total': 2,
+ },
+ };
+
+ mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response);
+
+ expect(state.matches.groupMilestones).toEqual({
+ list: [
+ {
+ title: 'group-0.1',
+ },
+ {
+ title: 'group-0.2',
+ },
+ ],
+ error: null,
+ totalCount: 2,
+ });
+ });
+
+ describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => {
+ it('updates state.matches.groupMilestones to an empty state with the error object', () => {
+ const error = new Error('Something went wrong!');
+
+ state.matches.groupMilestones = {
+ list: [{ title: 'group-0.1' }],
+ totalCount: 1,
+ error: null,
+ };
+
+ mutations[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error);
+
+ expect(state.matches.groupMilestones).toEqual({
+ list: [],
+ totalCount: 0,
+ error,
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js
index 16e2080c000..fbcff33d692 100644
--- a/spec/frontend/monitoring/components/charts/column_spec.js
+++ b/spec/frontend/monitoring/components/charts/column_spec.js
@@ -30,6 +30,7 @@ describe('Column component', () => {
},
metrics: [
{
+ label: 'Mock data',
result: [
{
metric: {},
@@ -96,7 +97,7 @@ describe('Column component', () => {
describe('wrapped components', () => {
describe('GitLab UI column chart', () => {
it('receives data properties needed for proper chart render', () => {
- expect(chartProps('data').values).toEqual(dataValues);
+ expect(chartProps('bars')).toEqual([{ name: 'Mock data', data: dataValues }]);
});
it('passes the y axis name correctly', () => {
diff --git a/spec/frontend/monitoring/components/charts/stacked_column_spec.js b/spec/frontend/monitoring/components/charts/stacked_column_spec.js
index 24a2af87eb8..2032258730a 100644
--- a/spec/frontend/monitoring/components/charts/stacked_column_spec.js
+++ b/spec/frontend/monitoring/components/charts/stacked_column_spec.js
@@ -44,19 +44,19 @@ describe('Stacked column chart component', () => {
});
it('data should match the graphData y value for each series', () => {
- const data = findChart().props('data');
+ const data = findChart().props('bars');
data.forEach((series, index) => {
const { values } = stackedColumnMockedData.metrics[index].result[0];
- expect(series).toEqual(values.map(value => value[1]));
+ expect(series.data).toEqual(values.map(value => value[1]));
});
});
- it('series names should be the same as the graphData metrics labels', () => {
- const seriesNames = findChart().props('seriesNames');
+ it('data should be the same length as the graphData metrics labels', () => {
+ const barDataProp = findChart().props('bars');
- expect(seriesNames).toHaveLength(stackedColumnMockedData.metrics.length);
- seriesNames.forEach((name, index) => {
+ expect(barDataProp).toHaveLength(stackedColumnMockedData.metrics.length);
+ barDataProp.forEach(({ name }, index) => {
expect(stackedColumnMockedData.metrics[index].label).toBe(name);
});
});
diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js
index 7f0ff534db3..8fcee80a2d8 100644
--- a/spec/frontend/monitoring/components/charts/time_series_spec.js
+++ b/spec/frontend/monitoring/components/charts/time_series_spec.js
@@ -226,7 +226,7 @@ describe('Time series component', () => {
]);
expect(
- shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltipContent', value),
+ shallowWrapperContainsSlotText(wrapper.find(GlLineChart), 'tooltip-content', value),
).toBe(true);
});
@@ -651,7 +651,7 @@ describe('Time series component', () => {
return wrapper.vm.$nextTick(() => {
expect(
- shallowWrapperContainsSlotText(findChartComponent(), 'tooltipTitle', mockTitle),
+ shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', mockTitle),
).toBe(true);
});
});
@@ -671,7 +671,7 @@ describe('Time series component', () => {
it('uses deployment title', () => {
expect(
- shallowWrapperContainsSlotText(findChartComponent(), 'tooltipTitle', 'Deployed'),
+ shallowWrapperContainsSlotText(findChartComponent(), 'tooltip-title', 'Deployed'),
).toBe(true);
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index ee0e1fd3176..1808faf8f0e 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -106,7 +106,7 @@ describe('Dashboard Panel', () => {
{},
{
slots: {
- topLeft: `<div class="top-left-content">OK</div>`,
+ 'top-left': `<div class="top-left-content">OK</div>`,
},
},
);
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index b7a0ea46b61..27e479ba498 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -508,7 +508,7 @@ describe('Dashboard', () => {
const mockKeyup = key => window.dispatchEvent(new KeyboardEvent('keyup', { key }));
const MockPanel = {
- template: `<div><slot name="topLeft"/></div>`,
+ template: `<div><slot name="top-left"/></div>`,
};
beforeEach(() => {
diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
index b63995ec2d4..01089752933 100644
--- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js
+++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js
@@ -73,7 +73,7 @@ describe('Embed Group', () => {
metricsWithDataGetter.mockReturnValue([1]);
mountComponent({ shallow: false, stubs: { MetricEmbed: true } });
- expect(wrapper.find('.card-body').classes()).not.toContain('d-none');
+ expect(wrapper.find('.gl-card-body').classes()).not.toContain('d-none');
});
it('collapses when clicked', done => {
@@ -83,7 +83,7 @@ describe('Embed Group', () => {
wrapper.find(GlButton).trigger('click');
wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.card-body').classes()).toContain('d-none');
+ expect(wrapper.find('.gl-card-body').classes()).toContain('d-none');
done();
});
});
diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
index 788f3abf617..cc384aef231 100644
--- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
+++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import DropdownField from '~/monitoring/components/variables/dropdown_field.vue';
describe('Custom variable component', () => {
@@ -23,8 +23,8 @@ describe('Custom variable component', () => {
});
};
- const findDropdown = () => wrapper.find(GlDeprecatedDropdown);
- const findDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem);
+ const findDropdown = () => wrapper.find(GlDropdown);
+ const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
it('renders dropdown element when all necessary props are passed', () => {
createShallowWrapper();
diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js
index 3e1e43d0c6a..b26eb00bfdc 100644
--- a/spec/frontend/notes/components/discussion_actions_spec.js
+++ b/spec/frontend/notes/components/discussion_actions_spec.js
@@ -13,11 +13,11 @@ const createDiscussionMock = (props = {}) =>
const createNoteMock = (props = {}) =>
Object.assign(JSON.parse(JSON.stringify(discussionMock.notes[0])), props);
const createResolvableNote = () =>
- createNoteMock({ resolvable: true, current_user: { can_resolve: true } });
+ createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: true } });
const createUnresolvableNote = () =>
- createNoteMock({ resolvable: false, current_user: { can_resolve: false } });
+ createNoteMock({ resolvable: false, current_user: { can_resolve_discussion: false } });
const createUnallowedNote = () =>
- createNoteMock({ resolvable: true, current_user: { can_resolve: false } });
+ createNoteMock({ resolvable: true, current_user: { can_resolve_discussion: false } });
describe('DiscussionActions', () => {
let wrapper;
diff --git a/spec/frontend/notes/components/discussion_filter_note_spec.js b/spec/frontend/notes/components/discussion_filter_note_spec.js
index 4701108d315..d35f8f7c28d 100644
--- a/spec/frontend/notes/components/discussion_filter_note_spec.js
+++ b/spec/frontend/notes/components/discussion_filter_note_spec.js
@@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
+import { GlButton, GlSprintf } from '@gitlab/ui';
import DiscussionFilterNote from '~/notes/components/discussion_filter_note.vue';
import eventHub from '~/notes/event_hub';
@@ -6,7 +7,11 @@ describe('DiscussionFilterNote component', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMount(DiscussionFilterNote);
+ wrapper = shallowMount(DiscussionFilterNote, {
+ stubs: {
+ GlSprintf,
+ },
+ });
};
beforeEach(() => {
@@ -19,21 +24,27 @@ describe('DiscussionFilterNote component', () => {
});
it('timelineContent renders a string containing instruction for switching feed type', () => {
- expect(wrapper.find({ ref: 'timelineContent' }).html()).toBe(
- "<div>You're only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.</div>",
+ expect(wrapper.find('[data-testid="discussion-filter-timeline-content"]').html()).toBe(
+ '<div data-testid="discussion-filter-timeline-content">You\'re only seeing <b>other activity</b> in the feed. To add a comment, switch to one of the following options.</div>',
);
});
it('emits `dropdownSelect` event with 0 parameter on clicking Show all activity button', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- wrapper.find({ ref: 'showAllActivity' }).vm.$emit('click');
+ wrapper
+ .findAll(GlButton)
+ .at(0)
+ .vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('dropdownSelect', 0);
});
it('emits `dropdownSelect` event with 1 parameter on clicking Show comments only button', () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
- wrapper.find({ ref: 'showComments' }).vm.$emit('click');
+ wrapper
+ .findAll(GlButton)
+ .at(1)
+ .vm.$emit('click');
expect(eventHub.$emit).toHaveBeenCalledWith('dropdownSelect', 1);
});
diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js
index a79c3bbacb7..f01c6c6b84e 100644
--- a/spec/frontend/notes/components/note_actions_spec.js
+++ b/spec/frontend/notes/components/note_actions_spec.js
@@ -1,5 +1,5 @@
import Vue from 'vue';
-import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils';
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
import { TEST_HOST } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter';
import createStore from '~/notes/stores';
@@ -14,9 +14,9 @@ describe('noteActions', () => {
let actions;
let axiosMock;
- const shallowMountNoteActions = (propsData, computed) => {
+ const mountNoteActions = (propsData, computed) => {
const localVue = createLocalVue();
- return shallowMount(localVue.extend(noteActions), {
+ return mount(localVue.extend(noteActions), {
store,
propsData,
localVue,
@@ -61,7 +61,7 @@ describe('noteActions', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
- wrapper = shallowMountNoteActions(props);
+ wrapper = mountNoteActions(props);
});
it('should render noteable author badge', () => {
@@ -178,7 +178,7 @@ describe('noteActions', () => {
};
beforeEach(() => {
- wrapper = shallowMountNoteActions(props, {
+ wrapper = mountNoteActions(props, {
targetType: () => 'issue',
});
store.state.noteableData = {
@@ -205,7 +205,7 @@ describe('noteActions', () => {
};
beforeEach(() => {
- wrapper = shallowMountNoteActions(props, {
+ wrapper = mountNoteActions(props, {
targetType: () => 'issue',
});
});
@@ -221,7 +221,7 @@ describe('noteActions', () => {
describe('user is not logged in', () => {
beforeEach(() => {
store.dispatch('setUserData', {});
- wrapper = shallowMountNoteActions({
+ wrapper = mountNoteActions({
...props,
canDelete: false,
canEdit: false,
@@ -241,7 +241,7 @@ describe('noteActions', () => {
describe('for showReply = true', () => {
beforeEach(() => {
- wrapper = shallowMountNoteActions({
+ wrapper = mountNoteActions({
...props,
showReply: true,
});
@@ -256,7 +256,7 @@ describe('noteActions', () => {
describe('for showReply = false', () => {
beforeEach(() => {
- wrapper = shallowMountNoteActions({
+ wrapper = mountNoteActions({
...props,
showReply: false,
});
@@ -273,7 +273,7 @@ describe('noteActions', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
- wrapper = shallowMountNoteActions({ ...props, canResolve: true, isDraft: true });
+ wrapper = mountNoteActions({ ...props, canResolve: true, isDraft: true });
});
it('should render the right resolve button title', () => {
diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js
index dce5424f154..5ab183e5452 100644
--- a/spec/frontend/notes/components/note_awards_list_spec.js
+++ b/spec/frontend/notes/components/note_awards_list_spec.js
@@ -92,15 +92,14 @@ describe('note_awards_list component', () => {
}).$mount();
};
- const findTooltip = () =>
- vm.$el.querySelector('[data-original-title]').getAttribute('data-original-title');
+ const findTooltip = () => vm.$el.querySelector('[title]').getAttribute('title');
it('should only escape & and " characters', () => {
awardsMock = [...new Array(1)].map(createAwardEmoji);
mountComponent();
const escapedName = awardsMock[0].user.name.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
- expect(vm.$el.querySelector('[data-original-title]').outerHTML).toContain(escapedName);
+ expect(vm.$el.querySelector('[title]').outerHTML).toContain(escapedName);
});
it('should not escape special HTML characters twice when only 1 person awarded', () => {
diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js
index a5b5204509e..cc434d6c952 100644
--- a/spec/frontend/notes/components/note_form_spec.js
+++ b/spec/frontend/notes/components/note_form_spec.js
@@ -272,6 +272,7 @@ describe('issue_note_form component', () => {
wrapper = createComponentWrapper();
wrapper.setProps({
...props,
+ isDraft: true,
noteId: '',
discussion: { ...discussionMock, for_commit: false },
});
@@ -292,6 +293,27 @@ describe('issue_note_form component', () => {
expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(true);
});
+ it('hides resolve checkbox', async () => {
+ wrapper.setProps({
+ isDraft: false,
+ discussion: {
+ ...discussionMock,
+ notes: [
+ ...discussionMock.notes.map(n => ({
+ ...n,
+ resolvable: true,
+ current_user: { ...n.current_user, can_resolve_discussion: false },
+ })),
+ ],
+ for_commit: false,
+ },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find('.js-resolve-checkbox').exists()).toBe(false);
+ });
+
it('hides actions for commits', () => {
wrapper.setProps({ discussion: { for_commit: true } });
diff --git a/spec/frontend/notes/components/note_header_spec.js b/spec/frontend/notes/components/note_header_spec.js
index 2bb08b60569..69aab0d051e 100644
--- a/spec/frontend/notes/components/note_header_spec.js
+++ b/spec/frontend/notes/components/note_header_spec.js
@@ -1,7 +1,9 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vuex from 'vuex';
+import { GlSprintf } from '@gitlab/ui';
import NoteHeader from '~/notes/components/note_header.vue';
+import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -28,6 +30,9 @@ describe('NoteHeader component', () => {
path: '/root',
state: 'active',
username: 'root',
+ status: {
+ availability: '',
+ },
};
const createComponent = props => {
@@ -37,6 +42,7 @@ describe('NoteHeader component', () => {
actions,
}),
propsData: { ...props },
+ stubs: { GlSprintf },
});
};
@@ -78,7 +84,7 @@ describe('NoteHeader component', () => {
expanded: true,
});
- expect(findChevronIcon().classes()).toContain('fa-chevron-up');
+ expect(findChevronIcon().props('name')).toBe('chevron-up');
});
it('has chevron-down icon if expanded prop is false', () => {
@@ -87,7 +93,7 @@ describe('NoteHeader component', () => {
expanded: false,
});
- expect(findChevronIcon().classes()).toContain('fa-chevron-down');
+ expect(findChevronIcon().props('name')).toBe('chevron-down');
});
});
@@ -97,6 +103,12 @@ describe('NoteHeader component', () => {
expect(wrapper.find('.js-user-link').exists()).toBe(true);
});
+ it('renders busy status if author availability is set', () => {
+ createComponent({ author: { ...author, status: { availability: AVAILABILITY_STATUS.BUSY } } });
+
+ expect(wrapper.find('.js-user-link').text()).toContain('(Busy)');
+ });
+
it('renders deleted user text if author is not passed as a prop', () => {
createComponent();
diff --git a/spec/frontend/notes/mock_data.js b/spec/frontend/notes/mock_data.js
index 4ff64abe4cc..638a4edecd6 100644
--- a/spec/frontend/notes/mock_data.js
+++ b/spec/frontend/notes/mock_data.js
@@ -7,7 +7,7 @@ export const notesDataMock = {
newSessionPath: '/users/sign_in?redirect_to_referer=yes',
notesPath: '/gitlab-org/gitlab-foss/noteable/issue/98/notes',
quickActionsDocsPath: '/help/user/project/quick_actions',
- registerPath: '/users/sign_in?redirect_to_referer=yes#register-pane',
+ registerPath: '/users/sign_up?redirect_to_referer=yes',
prerenderedNotesCount: 1,
closePath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=close',
reopenPath: '/twitter/flight/issues/9.json?issue%5Bstate_event%5D=reopen',
@@ -202,6 +202,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -249,6 +250,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
@@ -296,6 +298,7 @@ export const discussionMock = {
can_edit: true,
can_award_emoji: true,
can_resolve: true,
+ can_resolve_discussion: true,
},
discussion_id: '9e3bd2f71a01de45fd166e6719eb380ad9f270b1',
emoji_awardable: true,
diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js
index d0ed78418af..61c6e824ab7 100644
--- a/spec/frontend/packages/details/components/package_title_spec.js
+++ b/spec/frontend/packages/details/components/package_title_spec.js
@@ -1,5 +1,6 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import PackageTitle from '~/packages/details/components/package_title.vue';
import PackageTags from '~/packages/shared/components/package_tags.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -53,6 +54,7 @@ describe('PackageTitle', () => {
const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]');
const packageRef = () => wrapper.find('[data-testid="package-ref"]');
const packageTags = () => wrapper.find(PackageTags);
+ const packageBadges = () => wrapper.findAll('[data-testid="tag-badge"]');
afterEach(() => {
wrapper.destroy();
@@ -70,6 +72,14 @@ describe('PackageTitle', () => {
expect(wrapper.element).toMatchSnapshot();
});
+
+ it('with tags on mobile', async () => {
+ jest.spyOn(GlBreakpointInstance, 'isDesktop').mockReturnValue(false);
+ await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } });
+ await wrapper.vm.$nextTick();
+
+ expect(packageBadges()).toHaveLength(mockTags.length);
+ });
});
describe('package title', () => {
diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
index ce3a58c856d..d27038e765f 100644
--- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
+++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap
@@ -10,7 +10,7 @@ exports[`packages_list_app renders 1`] = `
activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo"
class="gl-tabs"
contentclass=",gl-tab-content"
- navclass="gl-tabs-nav"
+ navclass=",gl-tabs-nav"
nofade="true"
nonavstyle="true"
tag="div"
diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
index 1fa12cf1365..f969808d78b 100644
--- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js
+++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js
@@ -32,10 +32,9 @@ describe('Promote label modal', () => {
});
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);
+ expect(vm.labelColor).not.toBe(null);
+ expect(vm.labelColor).toBe(labelMockData.labelColor);
+ expect(vm.labelTitle).toBe(labelMockData.labelTitle);
});
});
diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
index 5da998d9d2d..cfe54016410 100644
--- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
+++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/pipeline_schedule_callout_spec.js
@@ -1,109 +1,98 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
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';
-const imageUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
+const illustrationUrl = 'pages/projects/pipeline_schedules/shared/icons/intro_illustration.svg';
describe('Pipeline Schedule Callout', () => {
- let calloutComponent;
+ let wrapper;
- beforeEach(() => {
- setFixtures(`
- <div id='pipeline-schedules-callout' data-docs-url=${docsUrl} data-image-url=${imageUrl}></div>
- `);
- });
-
- describe('independent of cookies', () => {
- beforeEach(() => {
- calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
- });
-
- it('the component can be initialized', () => {
- expect(calloutComponent).toBeDefined();
+ const createComponent = () => {
+ wrapper = shallowMount(PipelineSchedulesCallout, {
+ provide: {
+ docsUrl,
+ illustrationUrl,
+ },
});
+ };
- it('correctly sets docsUrl', () => {
- expect(calloutComponent.docsUrl).toContain(docsUrl);
- });
-
- it('correctly sets imageUrl', () => {
- expect(calloutComponent.imageUrl).toContain(imageUrl);
- });
- });
+ const findInnerContentOfCallout = () => wrapper.find('[data-testid="innerContent"]');
+ const findDismissCalloutBtn = () => wrapper.find(GlButton);
describe(`when ${cookieKey} cookie is set`, () => {
- beforeEach(() => {
+ beforeEach(async () => {
Cookies.set(cookieKey, true);
- calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ createComponent();
+
+ await wrapper.vm.$nextTick();
});
- it('correctly sets calloutDismissed to true', () => {
- expect(calloutComponent.calloutDismissed).toBe(true);
+ afterEach(() => {
+ wrapper.destroy();
});
it('does not render the callout', () => {
- expect(calloutComponent.$el.childNodes.length).toBe(0);
+ expect(findInnerContentOfCallout().exists()).toBe(false);
});
});
describe('when cookie is not set', () => {
beforeEach(() => {
Cookies.remove(cookieKey);
- calloutComponent = new PipelineSchedulesCalloutComponent().$mount();
+ createComponent();
});
- it('correctly sets calloutDismissed to false', () => {
- expect(calloutComponent.calloutDismissed).toBe(false);
+ afterEach(() => {
+ wrapper.destroy();
});
it('renders the callout container', () => {
- expect(calloutComponent.$el.querySelector('.bordered-box')).not.toBeNull();
- });
-
- it('renders the callout img', () => {
- expect(calloutComponent.$el.outerHTML).toContain('<img');
+ expect(findInnerContentOfCallout().exists()).toBe(true);
});
it('renders the callout title', () => {
- expect(calloutComponent.$el.outerHTML).toContain('Scheduling Pipelines');
+ expect(wrapper.find('h4').text()).toBe('Scheduling Pipelines');
});
it('renders the callout text', () => {
- expect(calloutComponent.$el.outerHTML).toContain('runs pipelines in the future');
+ expect(wrapper.find('p').text()).toContain('runs pipelines in the future');
});
it('renders the documentation url', () => {
- expect(calloutComponent.$el.outerHTML).toContain(docsUrl);
+ expect(wrapper.find('a').attributes('href')).toBe(docsUrl);
});
- it('updates calloutDismissed when close button is clicked', done => {
- calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+ describe('methods', () => {
+ it('#dismissCallout sets calloutDismissed to true', async () => {
+ expect(wrapper.vm.calloutDismissed).toBe(false);
+
+ findDismissCalloutBtn().vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
- Vue.nextTick(() => {
- expect(calloutComponent.calloutDismissed).toBe(true);
- done();
+ expect(findInnerContentOfCallout().exists()).toBe(false);
});
- });
- it('#dismissCallout updates calloutDismissed', done => {
- calloutComponent.dismissCallout();
+ it('sets cookie on dismiss', () => {
+ const setCookiesSpy = jest.spyOn(Cookies, 'set');
+
+ findDismissCalloutBtn().vm.$emit('click');
- Vue.nextTick(() => {
- expect(calloutComponent.calloutDismissed).toBe(true);
- done();
+ expect(setCookiesSpy).toHaveBeenCalledWith('pipeline_schedules_callout_dismissed', true, {
+ expires: 365,
+ });
});
});
- it('is hidden when close button is clicked', done => {
- calloutComponent.$el.querySelector('#dismiss-callout-btn').click();
+ it('is hidden when close button is clicked', async () => {
+ findDismissCalloutBtn().vm.$emit('click');
- Vue.nextTick(() => {
- expect(calloutComponent.$el.childNodes.length).toBe(0);
- done();
- });
+ await wrapper.vm.$nextTick();
+
+ expect(findInnerContentOfCallout().exists()).toBe(false);
});
});
});
diff --git a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
index 0d9af0cb856..4b50342bf84 100644
--- a/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
+++ b/spec/frontend/pages/sessions/new/preserve_url_fragment_spec.js
@@ -14,18 +14,16 @@ describe('preserve_url_fragment', () => {
loadFixtures('sessions/new.html');
});
- it('adds the url fragment to all login and sign up form actions', () => {
+ it('adds the url fragment to the login form actions', () => {
preserveUrlFragment('#L65');
expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in#L65');
- expect($('#new_new_user').attr('action')).toBe('http://test.host/users#L65');
});
- it('does not add an empty url fragment to login and sign up form actions', () => {
+ it('does not add an empty url fragment to the login form actions', () => {
preserveUrlFragment();
expect($('#new_user').attr('action')).toBe('http://test.host/users/sign_in');
- expect($('#new_new_user').attr('action')).toBe('http://test.host/users');
});
it('does not add an empty query parameter to OmniAuth login buttons', () => {
diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js
index ff51b1184cb..739b45e2193 100644
--- a/spec/frontend/performance_bar/components/detailed_metric_spec.js
+++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import { trimText } from 'helpers/text_helper';
import DetailedMetric from '~/performance_bar/components/detailed_metric.vue';
@@ -14,6 +15,11 @@ describe('detailedMetric', () => {
});
};
+ const findAllTraceBlocks = () => wrapper.findAll('pre');
+ const findTraceBlockAtIndex = index => findAllTraceBlocks().at(index);
+ const findExpandBacktraceBtns = () => wrapper.findAll('[data-testid="backtrace-expand-btn"]');
+ const findExpandedBacktraceBtnAtIndex = index => findExpandBacktraceBtns().at(index);
+
afterEach(() => {
wrapper.destroy();
});
@@ -37,7 +43,12 @@ describe('detailedMetric', () => {
describe('when the current request has details', () => {
const requestDetails = [
{ duration: '100', feature: 'find_commit', request: 'abcdef', backtrace: ['hello', 'world'] },
- { duration: '23', feature: 'rebase_in_progress', request: '', backtrace: ['world', 'hello'] },
+ {
+ duration: '23',
+ feature: 'rebase_in_progress',
+ request: '',
+ backtrace: ['other', 'example'],
+ },
];
describe('with a default metric name', () => {
@@ -82,7 +93,7 @@ describe('detailedMetric', () => {
expect(request.text()).toContain(requestDetails[index].request);
});
- expect(wrapper.find('.text-expander.js-toggle-button')).not.toBeNull();
+ expect(wrapper.find('.js-toggle-button')).not.toBeNull();
wrapper.findAll('.performance-bar-modal td:nth-child(2)').wrappers.forEach(request => {
expect(request.text()).toContain('world');
@@ -96,6 +107,30 @@ describe('detailedMetric', () => {
it('displays request warnings', () => {
expect(wrapper.find(RequestWarning).exists()).toBe(true);
});
+
+ it('can open and close traces', async () => {
+ expect(findAllTraceBlocks()).toHaveLength(0);
+
+ // Each block click on a new trace and assert that the correct
+ // count is open and that the content is what we expect to ensure
+ // we opened or closed the right one
+ const secondExpandButton = findExpandedBacktraceBtnAtIndex(1);
+
+ findExpandedBacktraceBtnAtIndex(0).vm.$emit('click');
+ await nextTick();
+ expect(findAllTraceBlocks()).toHaveLength(1);
+ expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]);
+
+ secondExpandButton.vm.$emit('click');
+ await nextTick();
+ expect(findAllTraceBlocks()).toHaveLength(2);
+ expect(findTraceBlockAtIndex(1).text()).toContain(requestDetails[1].backtrace[0]);
+
+ secondExpandButton.vm.$emit('click');
+ await nextTick();
+ expect(findAllTraceBlocks()).toHaveLength(1);
+ expect(findTraceBlockAtIndex(0).text()).toContain(requestDetails[0].backtrace[0]);
+ });
});
describe('when using a custom metric title', () => {
@@ -140,7 +175,11 @@ describe('detailedMetric', () => {
});
});
- it('renders only the number of calls', () => {
+ it('renders only the number of calls', async () => {
+ expect(trimText(wrapper.text())).toEqual('456 notification bullet');
+
+ findExpandedBacktraceBtnAtIndex(0).vm.$emit('click');
+ await nextTick();
expect(trimText(wrapper.text())).toEqual('456 notification backtrace bullet');
});
});
diff --git a/spec/frontend/pipeline_editor/components/text_editor_spec.js b/spec/frontend/pipeline_editor/components/text_editor_spec.js
new file mode 100644
index 00000000000..39d205839f4
--- /dev/null
+++ b/spec/frontend/pipeline_editor/components/text_editor_spec.js
@@ -0,0 +1,41 @@
+import { shallowMount } from '@vue/test-utils';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import { mockCiYml } from '../mock_data';
+
+import TextEditor from '~/pipeline_editor/components/text_editor.vue';
+
+describe('~/pipeline_editor/components/text_editor.vue', () => {
+ let wrapper;
+
+ const createComponent = (props = {}, mountFn = shallowMount) => {
+ wrapper = mountFn(TextEditor, {
+ propsData: {
+ value: mockCiYml,
+ ...props,
+ },
+ });
+ };
+
+ const findEditor = () => wrapper.find(EditorLite);
+
+ it('contains an editor', () => {
+ createComponent();
+
+ expect(findEditor().exists()).toBe(true);
+ });
+
+ it('editor contains the value provided', () => {
+ expect(findEditor().props('value')).toBe(mockCiYml);
+ });
+
+ it('editor is readony and configured for .yml', () => {
+ expect(findEditor().props('editorOptions')).toEqual({ readOnly: true });
+ expect(findEditor().props('fileName')).toBe('*.yml');
+ });
+
+ it('bubbles up editor-ready event', () => {
+ findEditor().vm.$emit('editor-ready');
+
+ expect(wrapper.emitted('editor-ready')).toHaveLength(1);
+ });
+});
diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
new file mode 100644
index 00000000000..90acdf3ec0b
--- /dev/null
+++ b/spec/frontend/pipeline_editor/graphql/resolvers_spec.js
@@ -0,0 +1,42 @@
+import Api from '~/api';
+import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from '../mock_data';
+
+import { resolvers } from '~/pipeline_editor/graphql/resolvers';
+
+jest.mock('~/api', () => {
+ return {
+ getRawFile: jest.fn(),
+ };
+});
+
+describe('~/pipeline_editor/graphql/resolvers', () => {
+ describe('Query', () => {
+ describe('blobContent', () => {
+ beforeEach(() => {
+ Api.getRawFile.mockResolvedValue({
+ data: mockCiYml,
+ });
+ });
+
+ afterEach(() => {
+ Api.getRawFile.mockReset();
+ });
+
+ it('resolves lint data with type names', async () => {
+ const result = resolvers.Query.blobContent(null, {
+ projectPath: mockProjectPath,
+ path: mockCiConfigPath,
+ ref: mockDefaultBranch,
+ });
+
+ expect(Api.getRawFile).toHaveBeenCalledWith(mockProjectPath, mockCiConfigPath, {
+ ref: mockDefaultBranch,
+ });
+
+ // eslint-disable-next-line no-underscore-dangle
+ expect(result.__typename).toBe('BlobContent');
+ await expect(result.rawData).resolves.toBe(mockCiYml);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js
new file mode 100644
index 00000000000..96fa6e5e004
--- /dev/null
+++ b/spec/frontend/pipeline_editor/mock_data.js
@@ -0,0 +1,10 @@
+export const mockProjectPath = 'user1/project1';
+export const mockDefaultBranch = 'master';
+
+export const mockCiConfigPath = '.gitlab-ci.yml';
+export const mockCiYml = `
+job1:
+ stage: test
+ script:
+ - echo 'test'
+`;
diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
new file mode 100644
index 00000000000..46523baadf3
--- /dev/null
+++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js
@@ -0,0 +1,139 @@
+import { nextTick } from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlAlert, GlLoadingIcon, GlTabs, GlTab } from '@gitlab/ui';
+
+import { mockProjectPath, mockDefaultBranch, mockCiConfigPath, mockCiYml } from './mock_data';
+import TextEditor from '~/pipeline_editor/components/text_editor.vue';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
+import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue';
+
+describe('~/pipeline_editor/pipeline_editor_app.vue', () => {
+ let wrapper;
+
+ const createComponent = (
+ { props = {}, data = {}, loading = false } = {},
+ mountFn = shallowMount,
+ ) => {
+ wrapper = mountFn(PipelineEditorApp, {
+ propsData: {
+ projectPath: mockProjectPath,
+ defaultBranch: mockDefaultBranch,
+ ciConfigPath: mockCiConfigPath,
+ ...props,
+ },
+ data() {
+ return data;
+ },
+ stubs: {
+ GlTabs,
+ TextEditor,
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ content: {
+ loading,
+ },
+ },
+ },
+ },
+ });
+ };
+
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findTabAt = i => wrapper.findAll(GlTab).at(i);
+ const findEditorLite = () => wrapper.find(EditorLite);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays content', () => {
+ createComponent({ data: { content: mockCiYml } });
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ expect(findEditorLite().props('value')).toBe(mockCiYml);
+ });
+
+ it('displays a loading icon if the query is loading', () => {
+ createComponent({ loading: true });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ describe('tabs', () => {
+ it('displays tabs and their content', () => {
+ createComponent({ data: { content: mockCiYml } });
+
+ expect(
+ findTabAt(0)
+ .find(EditorLite)
+ .exists(),
+ ).toBe(true);
+ expect(
+ findTabAt(1)
+ .find(PipelineGraph)
+ .exists(),
+ ).toBe(true);
+ });
+
+ it('displays editor tab lazily, until editor is ready', async () => {
+ createComponent({ data: { content: mockCiYml } });
+
+ expect(findTabAt(0).attributes('lazy')).toBe('true');
+
+ findEditorLite().vm.$emit('editor-ready');
+ await nextTick();
+
+ expect(findTabAt(0).attributes('lazy')).toBe(undefined);
+ });
+ });
+
+ describe('when in error state', () => {
+ class MockError extends Error {
+ constructor(message, data) {
+ super(message);
+ if (data) {
+ this.networkError = {
+ response: { data },
+ };
+ }
+ }
+ }
+
+ it('shows a generic error', () => {
+ const error = new MockError('An error message');
+ createComponent({ data: { error } });
+
+ expect(findAlert().text()).toBe('CI file could not be loaded: An error message');
+ });
+
+ it('shows a ref missing error state', () => {
+ const error = new MockError('Ref missing!', {
+ error: 'ref is missing, ref is empty',
+ });
+ createComponent({ data: { error } });
+
+ expect(findAlert().text()).toMatch(
+ 'CI file could not be loaded: ref is missing, ref is empty',
+ );
+ });
+
+ it('shows a file missing error state', async () => {
+ const error = new MockError('File missing!', {
+ message: 'file not found',
+ });
+
+ await wrapper.setData({ error });
+
+ expect(findAlert().text()).toMatch('CI file could not be loaded: file not found');
+ });
+ });
+});
diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
index 040c0fbecc5..197f646a22e 100644
--- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
+++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js
@@ -1,5 +1,5 @@
import { mount, shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -35,6 +35,7 @@ describe('Pipeline New Form', () => {
const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]');
const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf);
const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data);
const createComponent = (term = '', props = {}, method = shallowMount) => {
@@ -207,6 +208,25 @@ describe('Pipeline New Form', () => {
window.gon = origGon;
});
+ describe('loading state', () => {
+ it('loading icon is shown when content is requested and hidden when received', async () => {
+ createComponent('', mockParams, mount);
+
+ mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {
+ [mockYmlKey]: {
+ value: mockYmlValue,
+ description: mockYmlDesc,
+ },
+ });
+
+ expect(findLoadingIcon().exists()).toBe(true);
+
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
describe('when yml defines a variable with description', () => {
beforeEach(async () => {
createComponent('', mockParams, mount);
diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js
index 062c9759a65..5a17be1af23 100644
--- a/spec/frontend/pipelines/graph/graph_component_spec.js
+++ b/spec/frontend/pipelines/graph/graph_component_spec.js
@@ -159,13 +159,13 @@ describe('graph component', () => {
describe('triggered by', () => {
describe('on click', () => {
- it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => {
+ it('should emit `onClickUpstreamPipeline` when triggered by linked pipeline is clicked', () => {
const btnWrapper = findExpandPipelineBtn();
btnWrapper.trigger('click');
btnWrapper.vm.$nextTick(() => {
- expect(wrapper.emitted().onClickTriggeredBy).toEqual([
+ expect(wrapper.emitted().onClickUpstreamPipeline).toEqual([
store.state.pipeline.triggered_by,
]);
});
diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
index 8e65f0d4f71..67986ca7739 100644
--- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js
@@ -2,11 +2,10 @@ import { mount } from '@vue/test-utils';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
-
import mockData from './linked_pipelines_mock_data';
+import { UPSTREAM, DOWNSTREAM } from '~/pipelines/components/graph/constants';
const mockPipeline = mockData.triggered[0];
-
const validTriggeredPipelineId = mockPipeline.project.id;
const invalidTriggeredPipelineId = mockPipeline.project.id + 5;
@@ -40,6 +39,7 @@ describe('Linked pipeline', () => {
pipeline: mockPipeline,
projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream',
+ type: DOWNSTREAM,
};
beforeEach(() => {
@@ -104,11 +104,13 @@ describe('Linked pipeline', () => {
pipeline: mockPipeline,
projectId: validTriggeredPipelineId,
columnTitle: 'Downstream',
+ type: DOWNSTREAM,
};
const upstreamProps = {
...downstreamProps,
columnTitle: 'Upstream',
+ type: UPSTREAM,
};
it('parent/child label container should exist', () => {
@@ -182,6 +184,7 @@ describe('Linked pipeline', () => {
pipeline: { ...mockPipeline, isLoading: true },
projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream',
+ type: DOWNSTREAM,
};
beforeEach(() => {
@@ -198,6 +201,7 @@ describe('Linked pipeline', () => {
pipeline: mockPipeline,
projectId: validTriggeredPipelineId,
columnTitle: 'Downstream',
+ type: DOWNSTREAM,
};
beforeEach(() => {
diff --git a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
index 82eaa553d0c..e6ae3154d1d 100644
--- a/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
+++ b/spec/frontend/pipelines/graph/linked_pipelines_column_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
+import { UPSTREAM } from '~/pipelines/components/graph/constants';
import mockData from './linked_pipelines_mock_data';
describe('Linked Pipelines Column', () => {
@@ -9,6 +10,7 @@ describe('Linked Pipelines Column', () => {
linkedPipelines: mockData.triggered,
graphPosition: 'right',
projectId: 19,
+ type: UPSTREAM,
};
let wrapper;
diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js
index 2e10b0f068c..03e385e3cc8 100644
--- a/spec/frontend/pipelines/header_component_spec.js
+++ b/spec/frontend/pipelines/header_component_spec.js
@@ -1,19 +1,19 @@
import { shallowMount } from '@vue/test-utils';
import { GlModal, GlLoadingIcon } from '@gitlab/ui';
-import MockAdapter from 'axios-mock-adapter';
import {
mockCancelledPipelineHeader,
mockFailedPipelineHeader,
mockRunningPipelineHeader,
mockSuccessfulPipelineHeader,
} from './mock_data';
-import axios from '~/lib/utils/axios_utils';
import HeaderComponent from '~/pipelines/components/header_component.vue';
+import deletePipelineMutation from '~/pipelines/graphql/mutations/delete_pipeline.mutation.graphql';
+import retryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql';
+import cancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql';
describe('Pipeline details header', () => {
let wrapper;
let glModalDirective;
- let mockAxios;
const findDeleteModal = () => wrapper.find(GlModal);
const findRetryButton = () => wrapper.find('[data-testid="retryPipeline"]');
@@ -25,9 +25,7 @@ describe('Pipeline details header', () => {
pipelineId: 14,
pipelineIid: 1,
paths: {
- retry: '/retry',
- cancel: '/cancel',
- delete: '/delete',
+ pipelinesPath: '/namespace/my-project/-/pipelines',
fullProject: '/namespace/my-project',
},
};
@@ -43,6 +41,7 @@ describe('Pipeline details header', () => {
startPolling: jest.fn(),
},
},
+ mutate: jest.fn(),
};
return shallowMount(HeaderComponent, {
@@ -65,16 +64,9 @@ describe('Pipeline details header', () => {
});
};
- beforeEach(() => {
- mockAxios = new MockAdapter(axios);
- mockAxios.onGet('*').replyOnce(200);
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
-
- mockAxios.restore();
});
describe('initial loading', () => {
@@ -105,19 +97,37 @@ describe('Pipeline details header', () => {
);
});
+ describe('polling', () => {
+ it('is stopped when pipeline is finished', async () => {
+ wrapper = createComponent({ ...mockRunningPipelineHeader });
+
+ await wrapper.setData({
+ pipeline: { ...mockCancelledPipelineHeader },
+ });
+
+ expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).toHaveBeenCalled();
+ });
+
+ it('is not stopped when pipeline is not finished', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.vm.$apollo.queries.pipeline.stopPolling).not.toHaveBeenCalled();
+ });
+ });
+
describe('actions', () => {
describe('Retry action', () => {
beforeEach(() => {
wrapper = createComponent(mockCancelledPipelineHeader);
});
- it('should call axios with the right path when retry button is clicked', async () => {
- jest.spyOn(axios, 'post');
+ it('should call retryPipeline Mutation with pipeline id', () => {
findRetryButton().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
- expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.retry);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: retryPipelineMutation,
+ variables: { id: mockCancelledPipelineHeader.id },
+ });
});
});
@@ -126,13 +136,13 @@ describe('Pipeline details header', () => {
wrapper = createComponent(mockRunningPipelineHeader);
});
- it('should call axios with the right path when cancel button is clicked', async () => {
- jest.spyOn(axios, 'post');
+ it('should call cancelPipeline Mutation with pipeline id', () => {
findCancelButton().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
- expect(axios.post).toHaveBeenCalledWith(defaultProvideOptions.paths.cancel);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: cancelPipelineMutation,
+ variables: { id: mockRunningPipelineHeader.id },
+ });
});
});
@@ -141,24 +151,21 @@ describe('Pipeline details header', () => {
wrapper = createComponent(mockFailedPipelineHeader);
});
- it('displays delete modal when clicking on delete and does not call the delete action', async () => {
- jest.spyOn(axios, 'delete');
+ it('displays delete modal when clicking on delete and does not call the delete action', () => {
findDeleteButton().vm.$emit('click');
- await wrapper.vm.$nextTick();
-
expect(findDeleteModal().props('modalId')).toBe(wrapper.vm.$options.DELETE_MODAL_ID);
expect(glModalDirective).toHaveBeenCalledWith(wrapper.vm.$options.DELETE_MODAL_ID);
- expect(axios.delete).not.toHaveBeenCalled();
+ expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled();
});
- it('should call delete path when modal is submitted', async () => {
- jest.spyOn(axios, 'delete');
+ it('should call deletePipeline Mutation with pipeline id when modal is submitted', () => {
findDeleteModal().vm.$emit('ok');
- await wrapper.vm.$nextTick();
-
- expect(axios.delete).toHaveBeenCalledWith(defaultProvideOptions.paths.delete);
+ expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
+ mutation: deletePipelineMutation,
+ variables: { id: mockFailedPipelineHeader.id },
+ });
});
});
});
diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js
index b50932deec6..4f55fdd6b28 100644
--- a/spec/frontend/pipelines/pipeline_graph/mock_data.js
+++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js
@@ -91,3 +91,18 @@ export const pipelineData = {
[jobId4]: {},
},
};
+
+export const singleStageData = {
+ stages: [
+ {
+ name: 'build',
+ groups: [
+ {
+ name: 'build_1',
+ jobs: [{ script: 'echo hello', stage: 'build' }],
+ id: jobId1,
+ },
+ ],
+ },
+ ],
+};
diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
index 30e192e5726..7c8ebc27974 100644
--- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
+++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { pipelineData } from './mock_data';
+import { pipelineData, singleStageData } from './mock_data';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue';
import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue';
@@ -18,6 +18,8 @@ describe('pipeline graph component', () => {
};
const findAllStagePills = () => wrapper.findAll(StagePill);
+ const findAllStageBackgroundElements = () => wrapper.findAll('[data-testid="stage-background"]');
+ const findStageBackgroundElementAt = index => findAllStageBackgroundElements().at(index);
const findAllJobPills = () => wrapper.findAll(JobPill);
afterEach(() => {
@@ -31,7 +33,9 @@ describe('pipeline graph component', () => {
});
it('renders an empty section', () => {
- expect(wrapper.text()).toContain('No content to show');
+ expect(wrapper.text()).toContain(
+ 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
+ );
expect(findAllStagePills()).toHaveLength(0);
expect(findAllJobPills()).toHaveLength(0);
});
@@ -41,12 +45,43 @@ describe('pipeline graph component', () => {
beforeEach(() => {
wrapper = createComponent();
});
+
it('renders the right number of stage pills', () => {
const expectedStagesLength = pipelineData.stages.length;
expect(findAllStagePills()).toHaveLength(expectedStagesLength);
});
+ it.each`
+ cssClass | expectedState
+ ${'gl-rounded-bottom-left-6'} | ${true}
+ ${'gl-rounded-top-left-6'} | ${true}
+ ${'gl-rounded-top-right-6'} | ${false}
+ ${'gl-rounded-bottom-right-6'} | ${false}
+ `(
+ 'rounds corner: $class should be $expectedState on the first element',
+ ({ cssClass, expectedState }) => {
+ const classes = findStageBackgroundElementAt(0).classes();
+
+ expect(classes.includes(cssClass)).toBe(expectedState);
+ },
+ );
+
+ it.each`
+ cssClass | expectedState
+ ${'gl-rounded-bottom-left-6'} | ${false}
+ ${'gl-rounded-top-left-6'} | ${false}
+ ${'gl-rounded-top-right-6'} | ${true}
+ ${'gl-rounded-bottom-right-6'} | ${true}
+ `(
+ 'rounds corner: $class should be $expectedState on the last element',
+ ({ cssClass, expectedState }) => {
+ const classes = findStageBackgroundElementAt(pipelineData.stages.length - 1).classes();
+
+ expect(classes.includes(cssClass)).toBe(expectedState);
+ },
+ );
+
it('renders the right number of job pills', () => {
// We count the number of jobs in the mock data
const expectedJobsLength = pipelineData.stages.reduce((acc, val) => {
@@ -56,4 +91,25 @@ describe('pipeline graph component', () => {
expect(findAllJobPills()).toHaveLength(expectedJobsLength);
});
});
+
+ describe('with only one stage', () => {
+ beforeEach(() => {
+ wrapper = createComponent({ pipelineData: singleStageData });
+ });
+
+ it.each`
+ cssClass | expectedState
+ ${'gl-rounded-bottom-left-6'} | ${true}
+ ${'gl-rounded-top-left-6'} | ${true}
+ ${'gl-rounded-top-right-6'} | ${true}
+ ${'gl-rounded-bottom-right-6'} | ${true}
+ `(
+ 'rounds corner: $class should be $expectedState on the only element',
+ ({ cssClass, expectedState }) => {
+ const classes = findStageBackgroundElementAt(0).classes();
+
+ expect(classes.includes(cssClass)).toBe(expectedState);
+ },
+ );
+ });
});
diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js
index 1298a2a1524..a272803f9b6 100644
--- a/spec/frontend/pipelines/pipelines_spec.js
+++ b/spec/frontend/pipelines/pipelines_spec.js
@@ -74,7 +74,6 @@ describe('Pipelines', () => {
const createComponent = (props = defaultProps, methods) => {
wrapper = mount(PipelinesComponent, {
- provide: { glFeatures: { filterPipelinesSearch: true } },
propsData: {
store: new Store(),
projectId: '21',
@@ -373,7 +372,6 @@ describe('Pipelines', () => {
});
it('should render table', () => {
- expect(wrapper.find('.table-holder').exists()).toBe(true);
expect(wrapper.findAll('.gl-responsive-table-row')).toHaveLength(
pipelines.pipelines.length + 1,
);
diff --git a/spec/frontend/pipelines/test_reports/test_case_details_spec.js b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
new file mode 100644
index 00000000000..9e66012818e
--- /dev/null
+++ b/spec/frontend/pipelines/test_reports/test_case_details_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue';
+import CodeBlock from '~/vue_shared/components/code_block.vue';
+
+const localVue = createLocalVue();
+
+describe('Test case details', () => {
+ let wrapper;
+ const defaultTestCase = {
+ classname: 'spec.test_spec',
+ name: 'Test#something cool',
+ formattedTime: '10.04ms',
+ system_output: 'Line 42 is broken',
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findName = () => wrapper.find('[data-testid="test-case-name"]');
+ const findDuration = () => wrapper.find('[data-testid="test-case-duration"]');
+ const findSystemOutput = () => wrapper.find('[data-testid="test-case-trace"]');
+
+ const createComponent = (testCase = {}) => {
+ wrapper = shallowMount(TestCaseDetails, {
+ localVue,
+ propsData: {
+ modalId: 'my-modal',
+ testCase: {
+ ...defaultTestCase,
+ ...testCase,
+ },
+ },
+ stubs: { CodeBlock, GlModal },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('required details', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the test case classname as modal title', () => {
+ expect(findModal().attributes('title')).toBe(defaultTestCase.classname);
+ });
+
+ it('renders the test case name', () => {
+ expect(findName().text()).toBe(defaultTestCase.name);
+ });
+
+ it('renders the test case duration', () => {
+ expect(findDuration().text()).toBe(defaultTestCase.formattedTime);
+ });
+ });
+
+ describe('when test case has system output', () => {
+ it('renders the test case system output', () => {
+ createComponent();
+
+ expect(findSystemOutput().text()).toContain(defaultTestCase.system_output);
+ });
+ });
+
+ describe('when test case does not have system output', () => {
+ it('does not render the test case system output', () => {
+ createComponent({ system_output: null });
+
+ expect(findSystemOutput().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
index 838e0606375..284099b000b 100644
--- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
+++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js
@@ -1,7 +1,7 @@
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { getJSONFixture } from 'helpers/fixtures';
-import { GlButton } from '@gitlab/ui';
+import { GlButton, GlFriendlyWrap } from '@gitlab/ui';
import SuiteTable from '~/pipelines/components/test_reports/test_suite_table.vue';
import * as getters from '~/pipelines/stores/test_reports/getters';
import { TestStatus } from '~/pipelines/constants';
@@ -40,6 +40,7 @@ describe('Test reports suite table', () => {
wrapper = shallowMount(SuiteTable, {
store,
localVue,
+ stubs: { GlFriendlyWrap },
});
};
diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js
new file mode 100644
index 00000000000..63e0b3d9c49
--- /dev/null
+++ b/spec/frontend/popovers/components/popovers_spec.js
@@ -0,0 +1,129 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlPopover } from '@gitlab/ui';
+import { useMockMutationObserver } from 'helpers/mock_dom_observer';
+import Popovers from '~/popovers/components/popovers.vue';
+
+describe('popovers/components/popovers.vue', () => {
+ const { trigger: triggerMutate, observersCount } = useMockMutationObserver();
+ let wrapper;
+
+ const buildWrapper = (...targets) => {
+ wrapper = shallowMount(Popovers);
+ wrapper.vm.addPopovers(targets);
+ return wrapper.vm.$nextTick();
+ };
+
+ const createPopoverTarget = (options = {}) => {
+ const target = document.createElement('button');
+ const dataset = {
+ title: 'default title',
+ content: 'some content',
+ ...options,
+ };
+
+ Object.entries(dataset).forEach(([key, value]) => {
+ target.dataset[key] = value;
+ });
+
+ document.body.appendChild(target);
+
+ return target;
+ };
+
+ const allPopovers = () => wrapper.findAll(GlPopover);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('addPopovers', () => {
+ it('attaches popovers to the targets specified', async () => {
+ const target = createPopoverTarget();
+ await buildWrapper(target);
+ expect(wrapper.find(GlPopover).props('target')).toBe(target);
+ });
+
+ it('does not attach a popover twice to the same element', async () => {
+ const target = createPopoverTarget();
+ buildWrapper(target);
+ wrapper.vm.addPopovers([target]);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findAll(GlPopover)).toHaveLength(1);
+ });
+
+ it('supports HTML content', async () => {
+ const content = 'content with <b>HTML</b>';
+ await buildWrapper(
+ createPopoverTarget({
+ content,
+ html: true,
+ }),
+ );
+ const html = wrapper.find(GlPopover).html();
+
+ expect(html).toContain(content);
+ });
+
+ it.each`
+ option | value
+ ${'placement'} | ${'bottom'}
+ ${'triggers'} | ${'manual'}
+ `('sets $option to $value when data-$option is set in target', async ({ option, value }) => {
+ await buildWrapper(createPopoverTarget({ [option]: value }));
+
+ expect(wrapper.find(GlPopover).props(option)).toBe(value);
+ });
+ });
+
+ describe('dispose', () => {
+ it('removes all popovers when elements is nil', async () => {
+ await buildWrapper(createPopoverTarget(), createPopoverTarget());
+
+ wrapper.vm.dispose();
+ await wrapper.vm.$nextTick();
+
+ expect(allPopovers()).toHaveLength(0);
+ });
+
+ it('removes the popovers that target the elements specified', async () => {
+ const target = createPopoverTarget();
+
+ await buildWrapper(target, createPopoverTarget());
+
+ wrapper.vm.dispose(target);
+ await wrapper.vm.$nextTick();
+
+ expect(allPopovers()).toHaveLength(1);
+ });
+ });
+
+ describe('observe', () => {
+ it('removes popover when target is removed from the document', async () => {
+ const target = createPopoverTarget();
+ await buildWrapper(target);
+
+ wrapper.vm.addPopovers([target, createPopoverTarget()]);
+ await wrapper.vm.$nextTick();
+
+ triggerMutate(document.body, {
+ entry: { removedNodes: [target] },
+ options: { childList: true },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(allPopovers()).toHaveLength(1);
+ });
+ });
+
+ it('disconnects mutation observer on beforeDestroy', async () => {
+ await buildWrapper(createPopoverTarget());
+
+ expect(observersCount()).toBe(1);
+
+ wrapper.destroy();
+ expect(observersCount()).toBe(0);
+ });
+});
diff --git a/spec/frontend/popovers/index_spec.js b/spec/frontend/popovers/index_spec.js
new file mode 100644
index 00000000000..ea3b78332d7
--- /dev/null
+++ b/spec/frontend/popovers/index_spec.js
@@ -0,0 +1,104 @@
+import { initPopovers, dispose, destroy } from '~/popovers';
+
+describe('popovers/index.js', () => {
+ let popoversApp;
+
+ const createPopoverTarget = (trigger = 'hover') => {
+ const target = document.createElement('button');
+ const dataset = {
+ title: 'default title',
+ content: 'some content',
+ toggle: 'popover',
+ trigger,
+ };
+
+ Object.entries(dataset).forEach(([key, value]) => {
+ target.dataset[key] = value;
+ });
+
+ document.body.appendChild(target);
+
+ return target;
+ };
+
+ const buildPopoversApp = () => {
+ popoversApp = initPopovers('[data-toggle="popover"]');
+ };
+
+ const triggerEvent = (target, eventName = 'mouseenter') => {
+ const event = new Event(eventName);
+
+ target.dispatchEvent(event);
+ };
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ destroy();
+ });
+
+ describe('initPopover', () => {
+ it('attaches a GlPopover for the elements specified in the selector', async () => {
+ const target = createPopoverTarget();
+
+ buildPopoversApp();
+
+ triggerEvent(target);
+
+ await popoversApp.$nextTick();
+ const html = document.querySelector('.gl-popover').innerHTML;
+
+ expect(document.querySelector('.gl-popover')).not.toBe(null);
+ expect(html).toContain('default title');
+ expect(html).toContain('some content');
+ });
+
+ it('supports triggering a popover via custom events', async () => {
+ const trigger = 'click';
+ const target = createPopoverTarget(trigger);
+
+ buildPopoversApp();
+ triggerEvent(target, trigger);
+
+ await popoversApp.$nextTick();
+
+ expect(document.querySelector('.gl-popover')).not.toBe(null);
+ expect(document.querySelector('.gl-popover').innerHTML).toContain('default title');
+ });
+
+ it('inits popovers on targets added after content load', async () => {
+ buildPopoversApp();
+
+ expect(document.querySelector('.gl-popover')).toBe(null);
+
+ const trigger = 'click';
+ const target = createPopoverTarget(trigger);
+ triggerEvent(target, trigger);
+ await popoversApp.$nextTick();
+
+ expect(document.querySelector('.gl-popover')).not.toBe(null);
+ });
+ });
+
+ describe('dispose', () => {
+ it('removes popovers that target the elements specified', async () => {
+ const fakeTarget = createPopoverTarget();
+ const target = createPopoverTarget();
+ buildPopoversApp();
+ triggerEvent(target);
+ triggerEvent(createPopoverTarget());
+ await popoversApp.$nextTick();
+
+ expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
+
+ dispose([fakeTarget]);
+ await popoversApp.$nextTick();
+
+ expect(document.querySelectorAll('.gl-popover')).toHaveLength(2);
+
+ dispose([target]);
+ await popoversApp.$nextTick();
+
+ expect(document.querySelectorAll('.gl-popover')).toHaveLength(1);
+ });
+ });
+});
diff --git a/spec/frontend/profile/account/components/update_username_spec.js b/spec/frontend/profile/account/components/update_username_spec.js
index be39a7f4d80..45e5e0f885f 100644
--- a/spec/frontend/profile/account/components/update_username_spec.js
+++ b/spec/frontend/profile/account/components/update_username_spec.js
@@ -1,173 +1,135 @@
-import Vue from 'vue';
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
-import mountComponent from 'helpers/vue_mount_component_helper';
import axios from '~/lib/utils/axios_utils';
-import updateUsername from '~/profile/account/components/update_username.vue';
+import UpdateUsername from '~/profile/account/components/update_username.vue';
describe('UpdateUsername component', () => {
const rootUrl = TEST_HOST;
const actionUrl = `${TEST_HOST}/update/username`;
- const username = 'hasnoname';
- const newUsername = 'new_username';
- let Component;
- let vm;
+ const defaultProps = {
+ actionUrl,
+ rootUrl,
+ initialUsername: 'hasnoname',
+ };
+ let wrapper;
let axiosMock;
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(UpdateUsername, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ GlModal,
+ },
+ });
+ };
+
beforeEach(() => {
axiosMock = new MockAdapter(axios);
- Component = Vue.extend(updateUsername);
- vm = mountComponent(Component, {
- actionUrl,
- rootUrl,
- initialUsername: username,
- });
+ createComponent();
});
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
axiosMock.restore();
});
const findElements = () => {
- const modalSelector = `#${vm.$options.modalId}`;
+ const modal = wrapper.find(GlModal);
return {
- input: vm.$el.querySelector(`#${vm.$options.inputId}`),
- openModalBtn: vm.$el.querySelector(`[data-target="${modalSelector}"]`),
- modal: vm.$el.querySelector(modalSelector),
- modalBody: vm.$el.querySelector(`${modalSelector} .modal-body`),
- modalHeader: vm.$el.querySelector(`${modalSelector} .modal-title`),
- confirmModalBtn: vm.$el.querySelector(`${modalSelector} .btn-warning`),
+ modal,
+ input: wrapper.find(`#${wrapper.vm.$options.inputId}`),
+ openModalBtn: wrapper.find('[data-testid="username-change-confirmation-modal"]'),
+ modalBody: modal.find('.modal-body'),
+ modalHeader: modal.find('.modal-title'),
+ confirmModalBtn: wrapper.find('.btn-warning'),
};
};
- it('has a disabled button if the username was not changed', done => {
- const { input, openModalBtn } = findElements();
- input.dispatchEvent(new Event('input'));
-
- Vue.nextTick()
- .then(() => {
- expect(vm.username).toBe(username);
- expect(vm.newUsername).toBe(username);
- expect(openModalBtn).toBeDisabled();
- })
- .then(done)
- .catch(done.fail);
+ it('has a disabled button if the username was not changed', async () => {
+ const { openModalBtn } = findElements();
+
+ await wrapper.vm.$nextTick();
+
+ expect(openModalBtn.props('disabled')).toBe(true);
});
- it('has an enabled button which if the username was changed', done => {
+ it('has an enabled button which if the username was changed', async () => {
const { input, openModalBtn } = findElements();
- input.value = newUsername;
- input.dispatchEvent(new Event('input'));
-
- Vue.nextTick()
- .then(() => {
- expect(vm.username).toBe(username);
- expect(vm.newUsername).toBe(newUsername);
- expect(openModalBtn).not.toBeDisabled();
- })
- .then(done)
- .catch(done.fail);
- });
- it('confirmation modal contains proper header and body', done => {
- const { modalBody, modalHeader } = findElements();
+ input.element.value = 'newUsername';
+ input.trigger('input');
- vm.newUsername = newUsername;
+ await wrapper.vm.$nextTick();
- Vue.nextTick()
- .then(() => {
- expect(modalHeader.textContent).toContain('Change username?');
- expect(modalBody.textContent).toContain(
- `You are going to change the username ${username} to ${newUsername}`,
- );
- })
- .then(done)
- .catch(done.fail);
+ expect(openModalBtn.props('disabled')).toBe(false);
});
- it('confirmation modal should escape usernames properly', done => {
- const { modalBody } = findElements();
+ describe('changing username', () => {
+ const newUsername = 'new_username';
- vm.username = '<i>Italic</i>';
- vm.newUsername = vm.username;
+ beforeEach(async () => {
+ createComponent();
+ wrapper.setData({ newUsername });
- Vue.nextTick()
- .then(() => {
- expect(modalBody.innerHTML).toContain('&lt;i&gt;Italic&lt;/i&gt;');
- expect(modalBody.innerHTML).not.toContain(vm.username);
- })
- .then(done)
- .catch(done.fail);
- });
+ await wrapper.vm.$nextTick();
+ });
- it('executes API call on confirmation button click', done => {
- const { confirmModalBtn } = findElements();
+ it('confirmation modal contains proper header and body', async () => {
+ const { modal } = findElements();
- axiosMock.onPut(actionUrl).replyOnce(() => [200, { message: 'Username changed' }]);
- jest.spyOn(axios, 'put');
+ expect(modal.attributes('title')).toBe('Change username?');
+ expect(modal.text()).toContain(
+ `You are going to change the username ${defaultProps.initialUsername} to ${newUsername}`,
+ );
+ });
- vm.newUsername = newUsername;
+ it('executes API call on confirmation button click', async () => {
+ axiosMock.onPut(actionUrl).replyOnce(() => [200, { message: 'Username changed' }]);
+ jest.spyOn(axios, 'put');
- Vue.nextTick()
- .then(() => {
- confirmModalBtn.click();
+ await wrapper.vm.onConfirm();
+ await wrapper.vm.$nextTick();
- expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } });
- })
- .then(done)
- .catch(done.fail);
- });
+ expect(axios.put).toHaveBeenCalledWith(actionUrl, { user: { username: newUsername } });
+ });
- it('sets the username after a successful update', done => {
- const { input, openModalBtn } = findElements();
+ it('sets the username after a successful update', async () => {
+ const { input, openModalBtn } = findElements();
- axiosMock.onPut(actionUrl).replyOnce(() => {
- expect(input).toBeDisabled();
- expect(openModalBtn).toBeDisabled();
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ expect(input.attributes('disabled')).toBe('disabled');
+ expect(openModalBtn.props('disabled')).toBe(true);
- return [200, { message: 'Username changed' }];
+ return [200, { message: 'Username changed' }];
+ });
+
+ await wrapper.vm.onConfirm();
+ await wrapper.vm.$nextTick();
+
+ expect(input.attributes('disabled')).toBe(undefined);
+ expect(openModalBtn.props('disabled')).toBe(true);
});
- vm.newUsername = newUsername;
-
- vm.onConfirm()
- .then(() => {
- expect(vm.username).toBe(newUsername);
- expect(vm.newUsername).toBe(newUsername);
- expect(input).not.toBeDisabled();
- expect(input.value).toBe(newUsername);
- expect(openModalBtn).toBeDisabled();
- })
- .then(done)
- .catch(done.fail);
- });
+ it('does not set the username after a erroneous update', async () => {
+ const { input, openModalBtn } = findElements();
- it('does not set the username after a erroneous update', done => {
- const { input, openModalBtn } = findElements();
+ axiosMock.onPut(actionUrl).replyOnce(() => {
+ expect(input.attributes('disabled')).toBe('disabled');
+ expect(openModalBtn.props('disabled')).toBe(true);
- axiosMock.onPut(actionUrl).replyOnce(() => {
- expect(input).toBeDisabled();
- expect(openModalBtn).toBeDisabled();
+ return [400, { message: 'Invalid username' }];
+ });
- return [400, { message: 'Invalid username' }];
+ await expect(wrapper.vm.onConfirm()).rejects.toThrow();
+ expect(input.attributes('disabled')).toBe(undefined);
+ expect(openModalBtn.props('disabled')).toBe(false);
});
-
- const invalidUsername = 'anything.git';
- vm.newUsername = invalidUsername;
-
- vm.onConfirm()
- .then(() => done.fail('Expected onConfirm to throw!'))
- .catch(() => {
- expect(vm.username).toBe(username);
- expect(vm.newUsername).toBe(invalidUsername);
- expect(input).not.toBeDisabled();
- expect(input.value).toBe(invalidUsername);
- expect(openModalBtn).not.toBeDisabled();
- })
- .then(done)
- .catch(done.fail);
});
});
diff --git a/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap
new file mode 100644
index 00000000000..2fd1fd6a04e
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/__snapshots__/integration_view_spec.js.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IntegrationView component should render IntegrationView properly 1`] = `
+<div
+ name="sourcegraph"
+>
+ <label
+ class="label-bold"
+ >
+
+ Foo
+
+ </label>
+
+ <gl-link-stub
+ class="has-tooltip"
+ href="http://foo.com/help"
+ title="More information"
+ >
+ <gl-icon-stub
+ class="vertical-align-middle"
+ name="question-o"
+ size="16"
+ />
+ </gl-link-stub>
+
+ <div
+ class="form-group form-check"
+ data-testid="profile-preferences-integration-form-group"
+ >
+ <input
+ data-testid="profile-preferences-integration-hidden-field"
+ name="user[foo_enabled]"
+ type="hidden"
+ value="0"
+ />
+
+ <input
+ class="form-check-input"
+ data-testid="profile-preferences-integration-checkbox"
+ id="user_foo_enabled"
+ name="user[foo_enabled]"
+ type="checkbox"
+ value="1"
+ />
+
+ <label
+ class="form-check-label"
+ for="user_foo_enabled"
+ >
+
+ Enable foo
+
+ </label>
+
+ <gl-form-text-stub
+ tag="div"
+ textvariant="muted"
+ >
+ <integration-help-text-stub
+ message="Click %{linkStart}Foo%{linkEnd}!"
+ messageurl="http://foo.com"
+ />
+ </gl-form-text-stub>
+ </div>
+</div>
+`;
diff --git a/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap b/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap
new file mode 100644
index 00000000000..4df92cf86a5
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/__snapshots__/profile_preferences_spec.js.snap
@@ -0,0 +1,51 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ProfilePreferences component should render ProfilePreferences properly 1`] = `
+<div
+ class="row gl-mt-3 js-preferences-form"
+>
+ <div
+ class="col-sm-12"
+ >
+ <hr
+ data-testid="profile-preferences-integrations-rule"
+ />
+ </div>
+
+ <div
+ class="col-lg-4 profile-settings-sidebar"
+ >
+ <h4
+ class="gl-mt-0"
+ data-testid="profile-preferences-integrations-heading"
+ >
+
+ Integrations
+
+ </h4>
+
+ <p>
+
+ Customize integrations with third party services.
+
+ </p>
+ </div>
+
+ <div
+ class="col-lg-8"
+ >
+ <integration-view-stub
+ config="[object Object]"
+ helplink="http://foo.com/help"
+ message="Click %{linkStart}Foo%{linkEnd}!"
+ messageurl="http://foo.com"
+ />
+ <integration-view-stub
+ config="[object Object]"
+ helplink="http://bar.com/help"
+ message="Click %{linkStart}Bar%{linkEnd}!"
+ messageurl="http://bar.com"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/profile/preferences/components/integration_view_spec.js b/spec/frontend/profile/preferences/components/integration_view_spec.js
new file mode 100644
index 00000000000..5d55a089119
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/integration_view_spec.js
@@ -0,0 +1,124 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlFormText } from '@gitlab/ui';
+import IntegrationView from '~/profile/preferences/components/integration_view.vue';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { integrationViews, userFields } from '../mock_data';
+
+const viewProps = convertObjectPropsToCamelCase(integrationViews[0]);
+
+describe('IntegrationView component', () => {
+ let wrapper;
+ const defaultProps = {
+ config: {
+ title: 'Foo',
+ label: 'Enable foo',
+ formName: 'foo_enabled',
+ },
+ ...viewProps,
+ };
+
+ function createComponent(options = {}) {
+ const { props = {}, provide = {} } = options;
+ return shallowMount(IntegrationView, {
+ provide: {
+ userFields,
+ ...provide,
+ },
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ }
+
+ function findCheckbox() {
+ return wrapper.find('[data-testid="profile-preferences-integration-checkbox"]');
+ }
+ function findFormGroup() {
+ return wrapper.find('[data-testid="profile-preferences-integration-form-group"]');
+ }
+ function findHiddenField() {
+ return wrapper.find('[data-testid="profile-preferences-integration-hidden-field"]');
+ }
+ function findFormGroupLabel() {
+ return wrapper.find('[data-testid="profile-preferences-integration-form-group"] label');
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render the title correctly', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.find('label.label-bold').text()).toBe('Foo');
+ });
+
+ it('should render the form correctly', () => {
+ wrapper = createComponent();
+
+ expect(findFormGroup().exists()).toBe(true);
+ expect(findHiddenField().exists()).toBe(true);
+ expect(findCheckbox().exists()).toBe(true);
+ expect(findCheckbox().attributes('id')).toBe('user_foo_enabled');
+ expect(findCheckbox().attributes('name')).toBe('user[foo_enabled]');
+ });
+
+ it('should have the checkbox value to be set to 1', () => {
+ wrapper = createComponent();
+
+ expect(findCheckbox().attributes('value')).toBe('1');
+ });
+
+ it('should have the hidden value to be set to 0', () => {
+ wrapper = createComponent();
+
+ expect(findHiddenField().attributes('value')).toBe('0');
+ });
+
+ it('should set the checkbox value to be true', () => {
+ wrapper = createComponent();
+
+ expect(findCheckbox().element.checked).toBe(true);
+ });
+
+ it('should set the checkbox value to be false when false is provided', () => {
+ wrapper = createComponent({
+ provide: {
+ userFields: {
+ foo_enabled: false,
+ },
+ },
+ });
+
+ expect(findCheckbox().element.checked).toBe(false);
+ });
+
+ it('should set the checkbox value to be false when not provided', () => {
+ wrapper = createComponent({ provide: { userFields: {} } });
+
+ expect(findCheckbox().element.checked).toBe(false);
+ });
+
+ it('should render the help text', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.find(GlFormText).exists()).toBe(true);
+ expect(wrapper.find(IntegrationHelpText).exists()).toBe(true);
+ });
+
+ it('should render the label correctly', () => {
+ wrapper = createComponent();
+
+ expect(findFormGroupLabel().text()).toBe('Enable foo');
+ });
+
+ it('should render IntegrationView properly', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/profile/preferences/components/profile_preferences_spec.js b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
new file mode 100644
index 00000000000..fcc27d8faaf
--- /dev/null
+++ b/spec/frontend/profile/preferences/components/profile_preferences_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+
+import ProfilePreferences from '~/profile/preferences/components/profile_preferences.vue';
+import IntegrationView from '~/profile/preferences/components/integration_view.vue';
+import { integrationViews, userFields } from '../mock_data';
+
+describe('ProfilePreferences component', () => {
+ let wrapper;
+ const defaultProvide = {
+ integrationViews: [],
+ userFields,
+ };
+
+ function createComponent(options = {}) {
+ const { props = {}, provide = {} } = options;
+ return shallowMount(ProfilePreferences, {
+ provide: {
+ ...defaultProvide,
+ ...provide,
+ },
+ propsData: props,
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should not render Integrations section', () => {
+ wrapper = createComponent();
+ const views = wrapper.findAll(IntegrationView);
+ const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
+ const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
+
+ expect(divider.exists()).toBe(false);
+ expect(heading.exists()).toBe(false);
+ expect(views).toHaveLength(0);
+ });
+
+ it('should render Integration section', () => {
+ wrapper = createComponent({ provide: { integrationViews } });
+ const divider = wrapper.find('[data-testid="profile-preferences-integrations-rule"]');
+ const heading = wrapper.find('[data-testid="profile-preferences-integrations-heading"]');
+ const views = wrapper.findAll(IntegrationView);
+
+ expect(divider.exists()).toBe(true);
+ expect(heading.exists()).toBe(true);
+ expect(views).toHaveLength(integrationViews.length);
+ });
+
+ it('should render ProfilePreferences properly', () => {
+ wrapper = createComponent({ provide: { integrationViews } });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/profile/preferences/mock_data.js b/spec/frontend/profile/preferences/mock_data.js
new file mode 100644
index 00000000000..d07d5f565dc
--- /dev/null
+++ b/spec/frontend/profile/preferences/mock_data.js
@@ -0,0 +1,18 @@
+export const integrationViews = [
+ {
+ name: 'sourcegraph',
+ help_link: 'http://foo.com/help',
+ message: 'Click %{linkStart}Foo%{linkEnd}!',
+ message_url: 'http://foo.com',
+ },
+ {
+ name: 'gitpod',
+ help_link: 'http://bar.com/help',
+ message: 'Click %{linkStart}Bar%{linkEnd}!',
+ message_url: 'http://bar.com',
+ },
+];
+
+export const userFields = {
+ foo_enabled: true,
+};
diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
index a0fd6012546..4eb5060cb0a 100644
--- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
+++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap
@@ -57,7 +57,7 @@ exports[`Project remove modal initialized matches the snapshot 1`] = `
</gl-alert-stub>
<p>
- This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc.
+ This action cannot be undone. You will lose this project's repository and all content: issues, merge requests, etc.
</p>
<p
diff --git a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
index ff0351bd099..ac87fe893b9 100644
--- a/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
+++ b/spec/frontend/projects/pipelines/charts/components/__snapshots__/statistics_list_spec.js.snap
@@ -11,7 +11,6 @@ exports[`StatisticsList matches the snapshot 1`] = `
4 pipelines
</strong>
</li>
-
<li>
<span>
Successful:
@@ -21,7 +20,6 @@ exports[`StatisticsList matches the snapshot 1`] = `
2 pipelines
</strong>
</li>
-
<li>
<span>
Failed:
@@ -31,7 +29,6 @@ exports[`StatisticsList matches the snapshot 1`] = `
2 pipelines
</strong>
</li>
-
<li>
<span>
Success ratio:
@@ -41,5 +38,14 @@ exports[`StatisticsList matches the snapshot 1`] = `
50%
</strong>
</li>
+ <li>
+ <span>
+ Total duration:
+ </span>
+
+ <strong>
+ 00:01:56
+ </strong>
+ </li>
</ul>
`;
diff --git a/spec/frontend/projects/pipelines/charts/components/app_spec.js b/spec/frontend/projects/pipelines/charts/components/app_spec.js
index 883f2bec5f7..0dd3407dbbc 100644
--- a/spec/frontend/projects/pipelines/charts/components/app_spec.js
+++ b/spec/frontend/projects/pipelines/charts/components/app_spec.js
@@ -45,7 +45,7 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(chart.exists()).toBeTruthy();
expect(chart.props('yAxisTitle')).toBe('Minutes');
expect(chart.props('xAxisTitle')).toBe('Commit');
- expect(chart.props('data')).toBe(wrapper.vm.timesChartTransformedData);
+ expect(chart.props('bars')).toBe(wrapper.vm.timesChartTransformedData);
expect(chart.props('option')).toBe(wrapper.vm.$options.timesChartOptions);
});
});
diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js
index db5164c8f99..84e0ccb828a 100644
--- a/spec/frontend/projects/pipelines/charts/mock_data.js
+++ b/spec/frontend/projects/pipelines/charts/mock_data.js
@@ -3,6 +3,7 @@ export const counts = {
success: 2,
total: 4,
successRatio: 50,
+ totalDuration: 116158,
};
export const timesChartData = {
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
index 62aeb4ddee5..7e74a5deee1 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js
@@ -1,7 +1,8 @@
-import { shallowMount, mount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import ServiceDeskRoot from '~/projects/settings_service_desk/components/service_desk_root.vue';
+import ServiceDeskSetting from '~/projects/settings_service_desk/components/service_desk_setting.vue';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
@@ -24,65 +25,6 @@ describe('ServiceDeskRoot', () => {
}
});
- it('fetches incoming email when there is no incoming email provided', () => {
- axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK);
-
- wrapper = shallowMount(ServiceDeskRoot, {
- propsData: {
- initialIsEnabled: true,
- initialIncomingEmail: '',
- endpoint,
- },
- });
-
- return wrapper.vm
- .$nextTick()
- .then(waitForPromises)
- .then(() => {
- expect(axiosMock.history.get).toHaveLength(1);
- });
- });
-
- it('does not fetch incoming email when there is an incoming email provided', () => {
- axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK);
-
- wrapper = shallowMount(ServiceDeskRoot, {
- propsData: {
- initialIsEnabled: true,
- initialIncomingEmail,
- endpoint,
- },
- });
-
- return wrapper.vm
- .$nextTick()
- .then(waitForPromises)
- .then(() => {
- expect(axiosMock.history.get).toHaveLength(0);
- });
- });
-
- it('shows an error message when incoming email is not fetched correctly', () => {
- axiosMock.onGet(endpoint).networkError();
-
- wrapper = shallowMount(ServiceDeskRoot, {
- propsData: {
- initialIsEnabled: true,
- initialIncomingEmail: '',
- endpoint,
- },
- });
-
- return wrapper.vm
- .$nextTick()
- .then(waitForPromises)
- .then(() => {
- expect(wrapper.html()).toContain(
- 'An error occurred while fetching the Service Desk address.',
- );
- });
- });
-
it('sends a request to toggle service desk off when the toggle is clicked from the on state', () => {
axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK);
@@ -221,4 +163,18 @@ describe('ServiceDeskRoot', () => {
expect(wrapper.html()).toContain('An error occured while making the changes:');
});
});
+
+ it('passes customEmail through updatedCustomEmail correctly', () => {
+ const customEmail = 'foo';
+
+ wrapper = mount(ServiceDeskRoot, {
+ propsData: {
+ initialIsEnabled: true,
+ endpoint,
+ customEmail,
+ },
+ });
+
+ expect(wrapper.find(ServiceDeskSetting).props('customEmail')).toEqual(customEmail);
+ });
});
diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
index cb46751f66a..173a7fc4e11 100644
--- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
+++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js
@@ -13,6 +13,7 @@ describe('ServiceDeskSetting', () => {
});
const findTemplateDropdown = () => wrapper.find('#service-desk-template-select');
+ const findIncomingEmail = () => wrapper.find('[data-testid="incoming-email"]');
describe('when isEnabled=true', () => {
describe('only isEnabled', () => {
@@ -35,7 +36,7 @@ describe('ServiceDeskSetting', () => {
it('should see loading spinner and not the incoming email', () => {
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- expect(wrapper.find('.incoming-email').exists()).toBe(false);
+ expect(findIncomingEmail().exists()).toBe(false);
});
});
});
@@ -73,7 +74,7 @@ describe('ServiceDeskSetting', () => {
});
it('should see email and not the loading spinner', () => {
- expect(wrapper.find('.incoming-email').element.value).toEqual(incomingEmail);
+ expect(findIncomingEmail().element.value).toEqual(incomingEmail);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(false);
});
@@ -85,6 +86,45 @@ describe('ServiceDeskSetting', () => {
});
});
+ describe('with customEmail', () => {
+ describe('customEmail is different than incomingEmail', () => {
+ const incomingEmail = 'foo@bar.com';
+ const customEmail = 'custom@bar.com';
+
+ beforeEach(() => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ incomingEmail,
+ customEmail,
+ },
+ });
+ });
+
+ it('should see custom email', () => {
+ expect(findIncomingEmail().element.value).toEqual(customEmail);
+ });
+ });
+
+ describe('customEmail is the same as incomingEmail', () => {
+ const email = 'foo@bar.com';
+
+ beforeEach(() => {
+ wrapper = mount(ServiceDeskSetting, {
+ propsData: {
+ isEnabled: true,
+ incomingEmail: email,
+ customEmail: email,
+ },
+ });
+ });
+
+ it('should see custom email', () => {
+ expect(findIncomingEmail().element.value).toEqual(email);
+ });
+ });
+ });
+
describe('templates dropdown', () => {
it('renders a dropdown to choose a template', () => {
wrapper = shallowMount(ServiceDeskSetting, {
diff --git a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js
index f9e4d55245a..3b960a95db4 100644
--- a/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js
+++ b/spec/frontend/projects/settings_service_desk/services/service_desk_service_spec.js
@@ -19,24 +19,6 @@ describe('ServiceDeskService', () => {
axiosMock.restore();
});
- describe('fetchIncomingEmail', () => {
- it('makes a request to fetch incoming email', () => {
- axiosMock.onGet(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
-
- return service.fetchIncomingEmail().then(response => {
- expect(response.data).toEqual(dummyResponse);
- });
- });
-
- it('fails on error response', () => {
- axiosMock.onGet(endpoint).networkError();
-
- return service.fetchIncomingEmail().catch(error => {
- expect(error.message).toBe(errorMessage);
- });
- });
- });
-
describe('toggleServiceDesk', () => {
it('makes a request to set service desk', () => {
axiosMock.onPut(endpoint).replyOnce(httpStatusCodes.OK, dummyResponse);
diff --git a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
index 437a2116f5c..2460851a6a4 100644
--- a/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
+++ b/spec/frontend/prometheus_metrics/prometheus_metrics_spec.js
@@ -27,7 +27,8 @@ describe('PrometheusMetrics', () => {
expect(prometheusMetrics.$monitoredMetricsEmpty).toBeDefined();
expect(prometheusMetrics.$monitoredMetricsList).toBeDefined();
expect(prometheusMetrics.$missingEnvVarPanel).toBeDefined();
- expect(prometheusMetrics.$panelToggle).toBeDefined();
+ expect(prometheusMetrics.$panelToggleRight).toBeDefined();
+ expect(prometheusMetrics.$panelToggleDown).toBeDefined();
expect(prometheusMetrics.$missingEnvVarMetricCount).toBeDefined();
expect(prometheusMetrics.$missingEnvVarMetricsList).toBeDefined();
});
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
index ef22979ca7d..3276ef911e3 100644
--- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js
@@ -22,7 +22,7 @@ describe('tags list row', () => {
let wrapper;
const [tag] = [...tagsListResponse.data];
- const defaultProps = { tag, isDesktop: true, index: 0 };
+ const defaultProps = { tag, isMobile: false, index: 0 };
const findCheckbox = () => wrapper.find(GlFormCheckbox);
const findName = () => wrapper.find('[data-testid="name"]');
@@ -114,7 +114,7 @@ describe('tags list row', () => {
});
it('on mobile has mw-s class', () => {
- mountComponent({ ...defaultProps, isDesktop: false });
+ mountComponent({ ...defaultProps, isMobile: true });
expect(findName().classes('mw-s')).toBe(true);
});
diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
index 401202026bb..ebeaa8ff870 100644
--- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
+++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js
@@ -14,7 +14,7 @@ describe('Tags List', () => {
const findDeleteButton = () => wrapper.find(GlButton);
const findListTitle = () => wrapper.find('[data-testid="list-title"]');
- const mountComponent = (propsData = { tags, isDesktop: true }) => {
+ const mountComponent = (propsData = { tags, isMobile: false }) => {
wrapper = shallowMount(component, {
propsData,
});
@@ -41,15 +41,15 @@ describe('Tags List', () => {
describe('delete button', () => {
it.each`
- inputTags | isDesktop | isVisible
- ${tags} | ${true} | ${true}
- ${tags} | ${false} | ${false}
- ${readOnlyTags} | ${true} | ${false}
- ${readOnlyTags} | ${false} | ${false}
+ inputTags | isMobile | isVisible
+ ${tags} | ${false} | ${true}
+ ${tags} | ${true} | ${false}
+ ${readOnlyTags} | ${false} | ${false}
+ ${readOnlyTags} | ${true} | ${false}
`(
- 'is $isVisible that delete button exists when tags is $inputTags and isDesktop is $isDesktop',
- ({ inputTags, isDesktop, isVisible }) => {
- mountComponent({ tags: inputTags, isDesktop });
+ 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile',
+ ({ inputTags, isMobile, isVisible }) => {
+ mountComponent({ tags: inputTags, isMobile });
expect(findDeleteButton().exists()).toBe(isVisible);
},
@@ -110,12 +110,6 @@ describe('Tags List', () => {
expect(rows.at(0).attributes()).toMatchObject({
first: 'true',
- isdesktop: 'true',
- });
-
- // The list has only two tags and for some reasons .at(-1) does not work
- expect(rows.at(1).attributes()).toMatchObject({
- isdesktop: 'true',
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
index b4471ab8122..551d1eee68d 100644
--- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js
@@ -1,6 +1,6 @@
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
-import { GlDeprecatedDropdown } from '@gitlab/ui';
+import { GlDropdown } from '@gitlab/ui';
import Tracking from '~/tracking';
import * as getters from '~/registry/explorer/stores/getters';
import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue';
@@ -23,7 +23,7 @@ describe('cli_commands', () => {
let wrapper;
let store;
- const findDropdownButton = () => wrapper.find(GlDeprecatedDropdown);
+ const findDropdownButton = () => wrapper.find(GlDropdown);
const findCodeInstruction = () => wrapper.findAll(CodeInstruction);
const mountComponent = () => {
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
index ce446e6d93e..9f7a2758ae1 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
@@ -19,7 +19,7 @@ describe('Image List Row', () => {
let wrapper;
const item = imagesListResponse.data[0];
- const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
+ const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
const findDeleteBtn = () => wrapper.find(DeleteButton);
const findClipboardButton = () => wrapper.find(ClipboardButton);
@@ -67,7 +67,12 @@ describe('Image List Row', () => {
mountComponent();
const link = findDetailsLink();
expect(link.html()).toContain(item.path);
- expect(link.props('to').name).toBe('details');
+ expect(link.props('to')).toMatchObject({
+ name: 'details',
+ params: {
+ id: item.id,
+ },
+ });
});
it('contains a clipboard button', () => {
diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
index b906e44a4f7..d730bfcde24 100644
--- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
+++ b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js
@@ -32,6 +32,10 @@ describe('Registry Breadcrumb', () => {
{ name: 'baz', meta: { nameGenerator } },
];
+ const state = {
+ imageDetails: { foo: 'bar' },
+ };
+
const findDivider = () => wrapper.find('.js-divider');
const findRootRoute = () => wrapper.find({ ref: 'rootRouteLink' });
const findChildRoute = () => wrapper.find({ ref: 'childRouteLink' });
@@ -52,6 +56,9 @@ describe('Registry Breadcrumb', () => {
routes,
},
},
+ $store: {
+ state,
+ },
},
});
};
@@ -80,7 +87,7 @@ describe('Registry Breadcrumb', () => {
});
it('the link text is calculated by nameGenerator', () => {
- expect(nameGenerator).toHaveBeenCalledWith(routes[0]);
+ expect(nameGenerator).toHaveBeenCalledWith(state);
expect(nameGenerator).toHaveBeenCalledTimes(1);
});
});
@@ -104,7 +111,7 @@ describe('Registry Breadcrumb', () => {
});
it('the link text is calculated by nameGenerator', () => {
- expect(nameGenerator).toHaveBeenCalledWith(routes[1]);
+ expect(nameGenerator).toHaveBeenCalledWith(state);
expect(nameGenerator).toHaveBeenCalledTimes(2);
});
});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index a7ffed4c9fd..da5f1840b5c 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -97,3 +97,14 @@ export const imagePagination = {
totalPages: 2,
nextPage: 2,
};
+
+export const imageDetailsMock = {
+ id: 1,
+ name: 'rails-32309',
+ path: 'gitlab-org/gitlab-test/rails-32309',
+ project_id: 1,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-32309',
+ created_at: '2020-06-29T10:23:47.838Z',
+ cleanup_policy_started_at: null,
+ delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
+};
diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js
index 86b52c4f06a..c09b7e0c067 100644
--- a/spec/frontend/registry/explorer/pages/details_spec.js
+++ b/spec/frontend/registry/explorer/pages/details_spec.js
@@ -14,9 +14,10 @@ import {
SET_TAGS_LIST_SUCCESS,
SET_TAGS_PAGINATION,
SET_INITIAL_STATE,
+ SET_IMAGE_DETAILS,
} from '~/registry/explorer/stores/mutation_types';
-import { tagsListResponse } from '../mock_data';
+import { tagsListResponse, imageDetailsMock } from '../mock_data';
import { DeleteModal } from '../stubs';
describe('Details Page', () => {
@@ -33,8 +34,7 @@ describe('Details Page', () => {
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
- const routeIdGenerator = override =>
- window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar', ...override }));
+ const routeId = 1;
const tagsArrayToSelectedTags = tags =>
tags.reduce((acc, c) => {
@@ -42,7 +42,7 @@ describe('Details Page', () => {
return acc;
}, {});
- const mountComponent = ({ options, routeParams } = {}) => {
+ const mountComponent = ({ options } = {}) => {
wrapper = shallowMount(component, {
store,
stubs: {
@@ -51,7 +51,7 @@ describe('Details Page', () => {
mocks: {
$route: {
params: {
- id: routeIdGenerator(routeParams),
+ id: routeId,
},
},
},
@@ -65,6 +65,7 @@ describe('Details Page', () => {
dispatchSpy.mockResolvedValue();
store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
+ store.commit(SET_IMAGE_DETAILS, imageDetailsMock);
jest.spyOn(Tracking, 'event');
});
@@ -73,6 +74,13 @@ describe('Details Page', () => {
wrapper = null;
});
+ describe('lifecycle events', () => {
+ it('calls the appropriate action on mount', () => {
+ mountComponent();
+ expect(dispatchSpy).toHaveBeenCalledWith('requestImageDetailsAndTagsList', routeId);
+ });
+ });
+
describe('when isLoading is true', () => {
beforeEach(() => {
store.commit(SET_MAIN_LOADING, true);
@@ -124,7 +132,7 @@ describe('Details Page', () => {
it('has the correct props bound', () => {
expect(findTagsList().props()).toMatchObject({
- isDesktop: true,
+ isMobile: false,
tags: store.state.tags,
});
});
@@ -194,8 +202,7 @@ describe('Details Page', () => {
dispatchSpy.mockResolvedValue();
findPagination().vm.$emit(GlPagination.model.event, 2);
expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', {
- params: wrapper.vm.$route.params.id,
- pagination: { page: 2 },
+ page: 2,
});
});
});
@@ -227,7 +234,6 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
- params: routeIdGenerator(),
});
});
});
@@ -242,7 +248,6 @@ describe('Details Page', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
- params: routeIdGenerator(),
});
});
});
@@ -257,7 +262,7 @@ describe('Details Page', () => {
it('has the correct props', () => {
mountComponent();
- expect(findDetailsHeader().props()).toEqual({ imageName: 'foo' });
+ expect(findDetailsHeader().props()).toEqual({ imageName: imageDetailsMock.name });
});
});
@@ -293,10 +298,14 @@ describe('Details Page', () => {
};
describe('when expiration_policy_started is not null', () => {
- const routeParams = { cleanup_policy_started_at: Date.now().toString() };
-
+ beforeEach(() => {
+ store.commit(SET_IMAGE_DETAILS, {
+ ...imageDetailsMock,
+ cleanup_policy_started_at: Date.now().toString(),
+ });
+ });
it('exists', () => {
- mountComponent({ routeParams });
+ mountComponent();
expect(findPartialCleanupAlert().exists()).toBe(true);
});
@@ -304,13 +313,13 @@ describe('Details Page', () => {
it('has the correct props', () => {
store.commit(SET_INITIAL_STATE, { ...config });
- mountComponent({ routeParams });
+ mountComponent();
expect(findPartialCleanupAlert().props()).toEqual({ ...config });
});
it('dismiss hides the component', async () => {
- mountComponent({ routeParams });
+ mountComponent();
expect(findPartialCleanupAlert().exists()).toBe(true);
findPartialCleanupAlert().vm.$emit('dismiss');
diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js
index fb93ab06ca8..dcd4d8015a4 100644
--- a/spec/frontend/registry/explorer/stores/actions_spec.js
+++ b/spec/frontend/registry/explorer/stores/actions_spec.js
@@ -1,18 +1,29 @@
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
+import createFlash from '~/flash';
+import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/explorer/stores/actions';
import * as types from '~/registry/explorer/stores/mutation_types';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
import { reposServerResponse, registryServerResponse } from '../mock_data';
+import * as utils from '~/registry/explorer/utils';
+import {
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ FETCH_TAGS_LIST_ERROR_MESSAGE,
+ FETCH_IMAGE_DETAILS_ERROR_MESSAGE,
+} from '~/registry/explorer/constants/index';
jest.mock('~/flash.js');
+jest.mock('~/registry/explorer/utils');
describe('Actions RegistryExplorer Store', () => {
let mock;
const endpoint = `${TEST_HOST}/endpoint.json`;
+ const url = `${endpoint}/1}`;
+ jest.spyOn(utils, 'pathGenerator').mockReturnValue(url);
+
beforeEach(() => {
mock = new MockAdapter(axios);
});
@@ -123,7 +134,7 @@ describe('Actions RegistryExplorer Store', () => {
],
[],
() => {
- expect(createFlash).toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
done();
},
);
@@ -131,15 +142,12 @@ describe('Actions RegistryExplorer Store', () => {
});
describe('fetch tags list', () => {
- const url = `${endpoint}/1}`;
- const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` }));
-
it('sets the tagsList', done => {
mock.onGet(url).replyOnce(200, registryServerResponse, {});
testAction(
actions.requestTagsList,
- { params },
+ {},
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
@@ -158,7 +166,7 @@ describe('Actions RegistryExplorer Store', () => {
it('should create flash on error', done => {
testAction(
actions.requestTagsList,
- { params },
+ {},
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
@@ -166,7 +174,7 @@ describe('Actions RegistryExplorer Store', () => {
],
[],
() => {
- expect(createFlash).toHaveBeenCalled();
+ expect(createFlash).toHaveBeenCalledWith({ message: FETCH_TAGS_LIST_ERROR_MESSAGE });
done();
},
);
@@ -176,8 +184,6 @@ describe('Actions RegistryExplorer Store', () => {
describe('request delete single tag', () => {
it('successfully performs the delete request', done => {
const deletePath = 'delete/path';
- const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}`, id: 1 }));
-
mock.onDelete(deletePath).replyOnce(200);
testAction(
@@ -186,7 +192,6 @@ describe('Actions RegistryExplorer Store', () => {
tag: {
destroy_path: deletePath,
},
- params,
},
{
tagsPagination: {},
@@ -202,7 +207,7 @@ describe('Actions RegistryExplorer Store', () => {
},
{
type: 'requestTagsList',
- payload: { pagination: {}, params },
+ payload: {},
},
],
done,
@@ -227,18 +232,55 @@ describe('Actions RegistryExplorer Store', () => {
});
});
- describe('request delete multiple tags', () => {
- const url = `project-path/registry/repository/foo/tags`;
- const params = window.btoa(JSON.stringify({ tags_path: `${url}?format=json` }));
+ describe('requestImageDetailsAndTagsList', () => {
+ it('sets the imageDetails and dispatch requestTagsList', done => {
+ const resolvedValue = { foo: 'bar' };
+ jest.spyOn(Api, 'containerRegistryDetails').mockResolvedValue({ data: resolvedValue });
+
+ testAction(
+ actions.requestImageDetailsAndTagsList,
+ 1,
+ {},
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_IMAGE_DETAILS, payload: resolvedValue },
+ ],
+ [
+ {
+ type: 'requestTagsList',
+ },
+ ],
+ done,
+ );
+ });
+
+ it('should create flash on error', done => {
+ jest.spyOn(Api, 'containerRegistryDetails').mockRejectedValue();
+ testAction(
+ actions.requestImageDetailsAndTagsList,
+ 1,
+ {},
+ [
+ { type: types.SET_MAIN_LOADING, payload: true },
+ { type: types.SET_MAIN_LOADING, payload: false },
+ ],
+ [],
+ () => {
+ expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE });
+ done();
+ },
+ );
+ });
+ });
+ describe('request delete multiple tags', () => {
it('successfully performs the delete request', done => {
- mock.onDelete(`${url}/bulk_destroy`).replyOnce(200);
+ mock.onDelete(url).replyOnce(200);
testAction(
actions.requestDeleteTags,
{
ids: [1, 2],
- params,
},
{
tagsPagination: {},
@@ -254,7 +296,7 @@ describe('Actions RegistryExplorer Store', () => {
},
{
type: 'requestTagsList',
- payload: { pagination: {}, params },
+ payload: {},
},
],
done,
@@ -268,7 +310,6 @@ describe('Actions RegistryExplorer Store', () => {
actions.requestDeleteTags,
{
ids: [1, 2],
- params,
},
{
tagsPagination: {},
diff --git a/spec/frontend/registry/explorer/stores/mutations_spec.js b/spec/frontend/registry/explorer/stores/mutations_spec.js
index 4ca0211cdc3..1908d3f0350 100644
--- a/spec/frontend/registry/explorer/stores/mutations_spec.js
+++ b/spec/frontend/registry/explorer/stores/mutations_spec.js
@@ -121,4 +121,13 @@ describe('Mutations Registry Explorer Store', () => {
expect(mockState).toEqual(expectedState);
});
});
+
+ describe('SET_IMAGE_DETAILS', () => {
+ it('should set imageDetails', () => {
+ const expectedState = { ...mockState, imageDetails: { foo: 'bar' } };
+ mutations[types.SET_IMAGE_DETAILS](mockState, { foo: 'bar' });
+
+ expect(mockState).toEqual(expectedState);
+ });
+ });
});
diff --git a/spec/frontend/registry/explorer/utils_spec.js b/spec/frontend/registry/explorer/utils_spec.js
new file mode 100644
index 00000000000..0cd4a1cec29
--- /dev/null
+++ b/spec/frontend/registry/explorer/utils_spec.js
@@ -0,0 +1,45 @@
+import { pathGenerator } from '~/registry/explorer/utils';
+
+describe('Utils', () => {
+ describe('pathGenerator', () => {
+ const imageDetails = {
+ path: 'foo/bar/baz',
+ name: 'baz',
+ id: 1,
+ };
+
+ it('returns the fetch url when no ending is passed', () => {
+ expect(pathGenerator(imageDetails)).toBe('/foo/bar/registry/repository/1/tags?format=json');
+ });
+
+ it('returns the url with an ending when is passed', () => {
+ expect(pathGenerator(imageDetails, '/foo')).toBe('/foo/bar/registry/repository/1/tags/foo');
+ });
+
+ it.each`
+ path | name | result
+ ${'foo/foo'} | ${''} | ${'/foo/foo/registry/repository/1/tags?format=json'}
+ ${'foo/foo/foo'} | ${'foo'} | ${'/foo/foo/registry/repository/1/tags?format=json'}
+ ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'}
+ ${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'}
+ ${'foo/foo/baz/foo/foo'} | ${'foo/foo'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'}
+ ${'foo/foo/baz/foo/bar'} | ${'foo/bar'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'}
+ ${'baz/foo/foo'} | ${'foo'} | ${'/baz/foo/registry/repository/1/tags?format=json'}
+ ${'baz/foo/bar'} | ${'foo'} | ${'/baz/foo/bar/registry/repository/1/tags?format=json'}
+ `('returns the correct path when path is $path and name is $name', ({ name, path, result }) => {
+ expect(pathGenerator({ id: 1, name, path })).toBe(result);
+ });
+
+ it('returns the url unchanged when imageDetails have no name', () => {
+ const imageDetailsWithoutName = {
+ path: 'foo/bar/baz',
+ name: '',
+ id: 1,
+ };
+
+ expect(pathGenerator(imageDetailsWithoutName)).toBe(
+ '/foo/bar/baz/registry/repository/1/tags?format=json',
+ );
+ });
+ });
+});
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 69953fb5e03..2ceb2655d40 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
@@ -123,7 +123,7 @@ exports[`Expiration Policy Form renders 1`] = `
disabled="true"
id="expiration-policy-name-matching"
noresize="true"
- placeholder=".*"
+ placeholder=""
trim=""
value=""
/>
diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap
index 25c108e45bc..f49d3d7b716 100644
--- a/spec/frontend/releases/__snapshots__/util_spec.js.snap
+++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap
@@ -5,9 +5,12 @@ Object {
"data": Array [
Object {
"_links": Object {
+ "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed",
+ "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed",
"editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit",
- "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
- "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
+ "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=merged",
+ "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
+ "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
"self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
"selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
},
@@ -15,7 +18,7 @@ Object {
"count": 8,
"links": Array [
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3",
"external": true,
"id": "gid://gitlab/Releases::Link/13",
"linkType": "image",
@@ -23,7 +26,7 @@ Object {
"url": "https://example.com/image",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2",
"external": true,
"id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
@@ -31,7 +34,7 @@ Object {
"url": "https://example.com/package",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1",
"external": false,
"id": "gid://gitlab/Releases::Link/11",
"linkType": "runbook",
@@ -39,7 +42,7 @@ Object {
"url": "http://localhost/releases-namespace/releases-project/runbook",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64",
"external": true,
"id": "gid://gitlab/Releases::Link/10",
"linkType": "other",
@@ -130,9 +133,12 @@ exports[`releases/util.js convertOneReleaseGraphQLResponse matches snapshot 1`]
Object {
"data": Object {
"_links": Object {
+ "closedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=closed",
+ "closedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=closed",
"editUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/edit",
- "issuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
- "mergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
+ "mergedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=merged",
+ "openedIssuesUrl": "http://localhost/releases-namespace/releases-project/-/issues?release_tag=v1.1&scope=all&state=opened",
+ "openedMergeRequestsUrl": "http://localhost/releases-namespace/releases-project/-/merge_requests?release_tag=v1.1&scope=all&state=opened",
"self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
"selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
},
@@ -140,7 +146,7 @@ Object {
"count": 8,
"links": Array [
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-3",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-3",
"external": true,
"id": "gid://gitlab/Releases::Link/13",
"linkType": "image",
@@ -148,7 +154,7 @@ Object {
"url": "https://example.com/image",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-2",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-2",
"external": true,
"id": "gid://gitlab/Releases::Link/12",
"linkType": "package",
@@ -156,7 +162,7 @@ Object {
"url": "https://example.com/package",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/awesome-app-1",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/awesome-app-1",
"external": false,
"id": "gid://gitlab/Releases::Link/11",
"linkType": "runbook",
@@ -164,7 +170,7 @@ Object {
"url": "http://localhost/releases-namespace/releases-project/runbook",
},
Object {
- "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/binaries/linux-amd64",
+ "directAssetUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1/downloads/binaries/linux-amd64",
"external": true,
"id": "gid://gitlab/Releases::Link/10",
"linkType": "other",
diff --git a/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap
new file mode 100644
index 00000000000..e53ea6b2ec6
--- /dev/null
+++ b/spec/frontend/releases/components/__snapshots__/issuable_stats_spec.js.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`~/releases/components/issuable_stats.vue matches snapshot 1`] = `
+"<div class=\\"gl-display-flex gl-flex-direction-column gl-flex-shrink-0 gl-mr-6 gl-mb-5\\"><span class=\\"gl-mb-2\\">
+ Items
+ <span class=\\"badge badge-muted badge-pill gl-badge sm\\"><!----> 10</span></span>
+ <div class=\\"gl-display-flex\\"><span data-testid=\\"open-stat\\" class=\\"gl-white-space-pre-wrap\\">Open: <a href=\\"path/to/opened/items\\" class=\\"gl-link\\">1</a></span> <span class=\\"gl-mx-2\\">•</span> <span data-testid=\\"merged-stat\\" class=\\"gl-white-space-pre-wrap\\">Merged: <a href=\\"path/to/merged/items\\" class=\\"gl-link\\">7</a></span> <span class=\\"gl-mx-2\\">•</span> <span data-testid=\\"closed-stat\\" class=\\"gl-white-space-pre-wrap\\">Closed: <a href=\\"path/to/closed/items\\" class=\\"gl-link\\">2</a></span></div>
+</div>"
+`;
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index d92bdc3b99a..1d409b5b590 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -24,9 +24,10 @@ describe('Release edit/new component', () => {
state = {
release,
markdownDocsPath: 'path/to/markdown/docs',
- updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
projectId: '8',
+ groupId: '42',
+ groupMilestonesAvailable: true,
};
actions = {
diff --git a/spec/frontend/releases/components/issuable_stats_spec.js b/spec/frontend/releases/components/issuable_stats_spec.js
new file mode 100644
index 00000000000..d8211ec2adc
--- /dev/null
+++ b/spec/frontend/releases/components/issuable_stats_spec.js
@@ -0,0 +1,114 @@
+import { GlLink } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { trimText } from 'helpers/text_helper';
+import IssuableStats from '~/releases/components/issuable_stats.vue';
+
+describe('~/releases/components/issuable_stats.vue', () => {
+ let wrapper;
+ let defaultProps;
+
+ const createComponent = propUpdates => {
+ wrapper = mount(IssuableStats, {
+ propsData: {
+ ...defaultProps,
+ ...propUpdates,
+ },
+ });
+ };
+
+ const findOpenStatLink = () => wrapper.find('[data-testid="open-stat"]').find(GlLink);
+ const findMergedStatLink = () => wrapper.find('[data-testid="merged-stat"]').find(GlLink);
+ const findClosedStatLink = () => wrapper.find('[data-testid="closed-stat"]').find(GlLink);
+
+ beforeEach(() => {
+ defaultProps = {
+ label: 'Items',
+ total: 10,
+ closed: 2,
+ merged: 7,
+ openedPath: 'path/to/opened/items',
+ closedPath: 'path/to/closed/items',
+ mergedPath: 'path/to/merged/items',
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('matches snapshot', () => {
+ createComponent();
+
+ expect(wrapper.html()).toMatchSnapshot();
+ });
+
+ describe('when only total and closed counts are provided', () => {
+ beforeEach(() => {
+ createComponent({ merged: undefined, mergedPath: undefined });
+ });
+
+ it('renders a label with the total count; also, the opened count and the closed count', () => {
+ expect(trimText(wrapper.text())).toMatchInterpolatedText('Items 10 Open: 8 • Closed: 2');
+ });
+ });
+
+ describe('when only total, merged, and closed counts are provided', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders a label with the total count; also, the opened count, the merged count, and the closed count', () => {
+ expect(wrapper.text()).toMatchInterpolatedText('Items 10 Open: 1 • Merged: 7 • Closed: 2');
+ });
+ });
+
+ describe('when path parameters are provided', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders the "open" stat as a link', () => {
+ const link = findOpenStatLink();
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(defaultProps.openedPath);
+ });
+
+ it('renders the "merged" stat as a link', () => {
+ const link = findMergedStatLink();
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(defaultProps.mergedPath);
+ });
+
+ it('renders the "closed" stat as a link', () => {
+ const link = findClosedStatLink();
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(defaultProps.closedPath);
+ });
+ });
+
+ describe('when path parameters are not provided', () => {
+ beforeEach(() => {
+ createComponent({
+ openedPath: undefined,
+ closedPath: undefined,
+ mergedPath: undefined,
+ });
+ });
+
+ it('does not render the "open" stat as a link', () => {
+ expect(findOpenStatLink().exists()).toBe(false);
+ });
+
+ it('does not render the "merged" stat as a link', () => {
+ expect(findMergedStatLink().exists()).toBe(false);
+ });
+
+ it('does not render the "closed" stat as a link', () => {
+ expect(findClosedStatLink().exists()).toBe(false);
+ });
+ });
+});
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 45f4eaa01a9..bb34693c757 100644
--- a/spec/frontend/releases/components/release_block_milestone_info_spec.js
+++ b/spec/frontend/releases/components/release_block_milestone_info_spec.js
@@ -31,7 +31,8 @@ describe('Release block milestone info', () => {
const milestoneProgressBarContainer = () => wrapper.find('.js-milestone-progress-bar-container');
const milestoneListContainer = () => wrapper.find('.js-milestone-list-container');
- const issuesContainer = () => wrapper.find('.js-issues-container');
+ const issuesContainer = () => wrapper.find('[data-testid="issue-stats"]');
+ const mergeRequestsContainer = () => wrapper.find('[data-testid="merge-request-stats"]');
describe('with default props', () => {
beforeEach(() => factory({ milestones }));
@@ -188,66 +189,32 @@ describe('Release block milestone info', () => {
expectAllZeros();
});
- describe('Issue links', () => {
- const findOpenIssuesLink = () => wrapper.find({ ref: 'openIssuesLink' });
- const findOpenIssuesText = () => wrapper.find({ ref: 'openIssuesText' });
- const findClosedIssuesLink = () => wrapper.find({ ref: 'closedIssuesLink' });
- const findClosedIssuesText = () => wrapper.find({ ref: 'closedIssuesText' });
-
- describe('when openIssuePath is provided', () => {
- const openIssuesPath = '/path/to/open/issues';
-
- beforeEach(() => {
- return factory({ milestones, openIssuesPath });
- });
-
- it('renders the open issues as a link', () => {
- expect(findOpenIssuesLink().exists()).toBe(true);
- expect(findOpenIssuesText().exists()).toBe(false);
- });
-
- it('renders the open issues link with the correct href', () => {
- expect(findOpenIssuesLink().attributes().href).toBe(openIssuesPath);
- });
- });
-
- describe('when openIssuePath is not provided', () => {
- beforeEach(() => {
- return factory({ milestones });
- });
+ describe('if the API response is missing the "mr_stats" property', () => {
+ beforeEach(() => factory({ milestones }));
- it('renders the open issues as plain text', () => {
- expect(findOpenIssuesLink().exists()).toBe(false);
- expect(findOpenIssuesText().exists()).toBe(true);
- });
+ it('does not render merge request stats', () => {
+ expect(mergeRequestsContainer().exists()).toBe(false);
});
+ });
- describe('when closedIssuePath is provided', () => {
- const closedIssuesPath = '/path/to/closed/issues';
-
- beforeEach(() => {
- return factory({ milestones, closedIssuesPath });
- });
-
- it('renders the closed issues as a link', () => {
- expect(findClosedIssuesLink().exists()).toBe(true);
- expect(findClosedIssuesText().exists()).toBe(false);
- });
+ describe('if the API response includes the "mr_stats" property', () => {
+ beforeEach(() => {
+ milestones = milestones.map(m => ({
+ ...m,
+ mrStats: {
+ total: 15,
+ merged: 12,
+ closed: 1,
+ },
+ }));
- it('renders the closed issues link with the correct href', () => {
- expect(findClosedIssuesLink().attributes().href).toBe(closedIssuesPath);
- });
+ return factory({ milestones });
});
- describe('when closedIssuePath is not provided', () => {
- beforeEach(() => {
- return factory({ milestones });
- });
-
- it('renders the closed issues as plain text', () => {
- expect(findClosedIssuesLink().exists()).toBe(false);
- expect(findClosedIssuesText().exists()).toBe(true);
- });
+ it('renders merge request stats', () => {
+ expect(trimText(mergeRequestsContainer().text())).toBe(
+ 'Merge Requests 30 Open: 4 • Merged: 24 • Closed: 2',
+ );
});
});
});
diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js
new file mode 100644
index 00000000000..c089ee3cc38
--- /dev/null
+++ b/spec/frontend/releases/components/releases_sort_spec.js
@@ -0,0 +1,66 @@
+import Vuex from 'vuex';
+import { GlSorting, GlSortingItem } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import ReleasesSort from '~/releases/components/releases_sort.vue';
+import createStore from '~/releases/stores';
+import createListModule from '~/releases/stores/modules/list';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('~/releases/components/releases_sort.vue', () => {
+ let wrapper;
+ let store;
+ let listModule;
+ const projectId = 8;
+
+ const createComponent = () => {
+ listModule = createListModule({ projectId });
+
+ store = createStore({
+ modules: {
+ list: listModule,
+ },
+ });
+
+ store.dispatch = jest.fn();
+
+ wrapper = shallowMount(ReleasesSort, {
+ store,
+ stubs: {
+ GlSortingItem,
+ },
+ localVue,
+ });
+ };
+
+ const findReleasesSorting = () => wrapper.find(GlSorting);
+ const findSortingItems = () => wrapper.findAll(GlSortingItem);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('has all the sortable items', () => {
+ expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length);
+ });
+
+ it('on sort change set sorting in vuex and emit event', () => {
+ findReleasesSorting().vm.$emit('sortDirectionChange');
+ expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { sort: 'asc' });
+ expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ });
+
+ it('on sort item click set sorting and emit event', () => {
+ const item = findSortingItems().at(0);
+ const { orderBy } = wrapper.vm.sortOptions[0];
+ item.vm.$emit('click');
+ expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { orderBy });
+ expect(wrapper.emitted('sort:changed')).toBeTruthy();
+ });
+});
diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js
index 70a195556df..d4110b57776 100644
--- a/spec/frontend/releases/components/tag_field_exsting_spec.js
+++ b/spec/frontend/releases/components/tag_field_exsting_spec.js
@@ -6,7 +6,6 @@ import createStore from '~/releases/stores';
import createDetailModule from '~/releases/stores/modules/detail';
const TEST_TAG_NAME = 'test-tag-name';
-const TEST_DOCS_PATH = '/help/test/docs/path';
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -24,21 +23,11 @@ describe('releases/components/tag_field_existing', () => {
const findInput = () => wrapper.find(GlFormInput);
const findHelp = () => wrapper.find('[data-testid="tag-name-help"]');
- const findHelpLink = () => {
- const link = findHelp().find('a');
-
- return {
- text: link.text(),
- href: link.attributes('href'),
- target: link.attributes('target'),
- };
- };
beforeEach(() => {
store = createStore({
modules: {
detail: createDetailModule({
- updateReleaseApiDocsPath: TEST_DOCS_PATH,
tagName: TEST_TAG_NAME,
}),
},
@@ -68,16 +57,8 @@ describe('releases/components/tag_field_existing', () => {
createComponent(mount);
expect(findHelp().text()).toMatchInterpolatedText(
- 'Changing a Release tag is only supported via Releases API. More information',
+ "The tag name can't be changed for an existing release.",
);
-
- const helpLink = findHelpLink();
-
- expect(helpLink).toEqual({
- text: 'More information',
- href: TEST_DOCS_PATH,
- target: '_blank',
- });
});
});
});
diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js
index d38f6766d4e..abd0db6a589 100644
--- a/spec/frontend/releases/stores/modules/detail/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js
@@ -47,7 +47,6 @@ describe('Release detail actions', () => {
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
- updateReleaseApiDocsPath: 'path/to/api/docs',
}),
...getters,
...rootState,
diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
index f3e84262754..88eddc4019c 100644
--- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js
@@ -18,7 +18,6 @@ describe('Release detail mutations', () => {
releasesPagePath: 'path/to/releases/page',
markdownDocsPath: 'path/to/markdown/docs',
markdownPreviewPath: 'path/to/markdown/preview',
- updateReleaseApiDocsPath: 'path/to/api/docs',
});
release = convertObjectPropsToCamelCase(originalRelease);
});
diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js
index 4e235e1d00f..35551b77dc4 100644
--- a/spec/frontend/releases/stores/modules/list/actions_spec.js
+++ b/spec/frontend/releases/stores/modules/list/actions_spec.js
@@ -6,6 +6,7 @@ import {
fetchReleasesGraphQl,
fetchReleasesRest,
receiveReleasesError,
+ setSorting,
} from '~/releases/stores/modules/list/actions';
import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types';
@@ -114,7 +115,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with a first variable', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE },
+ variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' },
});
});
});
@@ -127,7 +128,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with last and before variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
- variables: { fullPath: projectPath, last: PAGE_SIZE, before },
+ variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' },
});
});
});
@@ -140,7 +141,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with first and after variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
- variables: { fullPath: projectPath, first: PAGE_SIZE, after },
+ variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' },
});
});
});
@@ -156,6 +157,29 @@ describe('Releases State actions', () => {
);
});
});
+
+ describe('when the sort parameters are provided', () => {
+ it.each`
+ sort | orderBy | ReleaseSort
+ ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'}
+ ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'}
+ ${'asc'} | ${'created_at'} | ${'CREATED_ASC'}
+ ${'desc'} | ${'created_at'} | ${'CREATED_DESC'}
+ `(
+ 'correctly sets $ReleaseSort based on $sort and $orderBy',
+ ({ sort, orderBy, ReleaseSort }) => {
+ mockedState.sorting.sort = sort;
+ mockedState.sorting.orderBy = orderBy;
+
+ fetchReleasesGraphQl(vuexParams, { before: undefined, after: undefined });
+
+ expect(gqClient.query).toHaveBeenCalledWith({
+ query: allReleasesQuery,
+ variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort },
+ });
+ },
+ );
+ });
});
describe('when the request is successful', () => {
@@ -230,7 +254,11 @@ describe('Releases State actions', () => {
});
it('makes a REST query with a page query parameter', () => {
- expect(api.releases).toHaveBeenCalledWith(projectId, { page });
+ expect(api.releases).toHaveBeenCalledWith(projectId, {
+ page,
+ order_by: 'released_at',
+ sort: 'desc',
+ });
});
});
});
@@ -302,4 +330,16 @@ describe('Releases State actions', () => {
);
});
});
+
+ describe('setSorting', () => {
+ it('should commit SET_SORTING', () => {
+ return testAction(
+ setSorting,
+ { orderBy: 'released_at', sort: 'asc' },
+ null,
+ [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }],
+ [],
+ );
+ });
+ });
});
diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js
index 521418cbddb..78071573072 100644
--- a/spec/frontend/releases/stores/modules/list/mutations_spec.js
+++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js
@@ -80,4 +80,16 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.graphQlPageInfo).toEqual({});
});
});
+
+ describe('SET_SORTING', () => {
+ it('should merge the sorting object with sort value', () => {
+ mutations[types.SET_SORTING](stateCopy, { sort: 'asc' });
+ expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' });
+ });
+
+ it('should merge the sorting object with order_by value', () => {
+ mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' });
+ expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' });
+ });
+ });
});
diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js
index e7b7766c0d0..fd00a524628 100644
--- a/spec/frontend/releases/util_spec.js
+++ b/spec/frontend/releases/util_spec.js
@@ -22,7 +22,7 @@ describe('releases/util.js', () => {
tagName: 'tag-name',
name: 'Release name',
description: 'Release description',
- milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }],
+ milestones: ['13.2', '13.3'],
assets: {
links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }],
},
@@ -74,14 +74,14 @@ describe('releases/util.js', () => {
});
});
- describe('when release.milestones is falsy', () => {
- it('includes a "milestone" property in the returned result as an empty array', () => {
- const release = {};
-
- const expectedJson = {
- milestones: [],
+ describe('when milestones contains full milestone objects', () => {
+ it('converts the milestone objects into titles', () => {
+ const release = {
+ milestones: [{ title: '13.2' }, { title: '13.3' }, '13.4'],
};
+ const expectedJson = { milestones: ['13.2', '13.3', '13.4'] };
+
expect(releaseToApiJson(release)).toMatchObject(expectedJson);
});
});
diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
index 3e11af9c9df..f99dcbffdff 100644
--- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
+++ b/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js
@@ -1,10 +1,15 @@
+import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import component from '~/reports/codequality_report/components/codequality_issue_body.vue';
import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants';
describe('code quality issue body issue body', () => {
let wrapper;
+ const findSeverityIcon = () => wrapper.findByTestId('codequality-severity-icon');
+ const findGlIcon = () => wrapper.find(GlIcon);
+
const codequalityIssue = {
name:
'rubygem-rest-client: session fixation vulnerability via Set-Cookie headers in 30x redirection responses',
@@ -14,13 +19,15 @@ describe('code quality issue body issue body', () => {
urlPath: '/Gemfile.lock#L22',
};
- const mountWithStatus = initialStatus => {
- wrapper = shallowMount(component, {
- propsData: {
- issue: codequalityIssue,
- status: initialStatus,
- },
- });
+ const createComponent = (initialStatus, issue = codequalityIssue) => {
+ wrapper = extendedWrapper(
+ shallowMount(component, {
+ propsData: {
+ issue,
+ status: initialStatus,
+ },
+ }),
+ );
};
afterEach(() => {
@@ -28,17 +35,43 @@ describe('code quality issue body issue body', () => {
wrapper = null;
});
+ describe('severity rating', () => {
+ it.each`
+ severity | iconClass | iconName
+ ${'info'} | ${'text-primary-400'} | ${'severity-info'}
+ ${'minor'} | ${'text-warning-200'} | ${'severity-low'}
+ ${'major'} | ${'text-warning-400'} | ${'severity-medium'}
+ ${'critical'} | ${'text-danger-600'} | ${'severity-high'}
+ ${'blocker'} | ${'text-danger-800'} | ${'severity-critical'}
+ ${'unknown'} | ${'text-secondary-400'} | ${'severity-unknown'}
+ ${'invalid'} | ${'text-secondary-400'} | ${'severity-unknown'}
+ `(
+ 'renders correct icon for "$severity" severity rating',
+ ({ severity, iconClass, iconName }) => {
+ createComponent(STATUS_FAILED, {
+ ...codequalityIssue,
+ severity,
+ });
+ const icon = findGlIcon();
+
+ expect(findSeverityIcon().classes()).toContain(iconClass);
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe(iconName);
+ },
+ );
+ });
+
describe('with success', () => {
it('renders fixed label', () => {
- mountWithStatus(STATUS_SUCCESS);
+ createComponent(STATUS_SUCCESS);
expect(wrapper.text()).toContain('Fixed');
});
});
describe('without success', () => {
- it('renders fixed label', () => {
- mountWithStatus(STATUS_FAILED);
+ it('does not render fixed label', () => {
+ createComponent(STATUS_FAILED);
expect(wrapper.text()).not.toContain('Fixed');
});
@@ -46,7 +79,7 @@ describe('code quality issue body issue body', () => {
describe('name', () => {
it('renders name', () => {
- mountWithStatus(STATUS_NEUTRAL);
+ createComponent(STATUS_NEUTRAL);
expect(wrapper.text()).toContain(codequalityIssue.name);
});
@@ -54,7 +87,7 @@ describe('code quality issue body issue body', () => {
describe('path', () => {
it('renders the report-link path using the correct code quality issue', () => {
- mountWithStatus(STATUS_NEUTRAL);
+ createComponent(STATUS_NEUTRAL);
expect(wrapper.find('report-link-stub').props('issue')).toBe(codequalityIssue);
});
diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
index 556904b7da5..ae2718db17f 100644
--- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js
+++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js
@@ -1,11 +1,13 @@
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { mockTracking } from 'helpers/tracking_helper';
import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue';
import { getStoreConfig } from '~/reports/store';
import { failedReport } from '../mock_data/mock_data';
import successTestReports from '../mock_data/no_failures_report.json';
import newFailedTestReports from '../mock_data/new_failures_report.json';
+import recentFailuresTestReports from '../mock_data/recent_failures_report.json';
import newErrorsTestReports from '../mock_data/new_errors_report.json';
import mixedResultsTestReports from '../mock_data/new_and_fixed_failures_report.json';
import resolvedFailures from '../mock_data/resolved_failures.json';
@@ -20,7 +22,7 @@ describe('Grouped test reports app', () => {
let wrapper;
let mockStore;
- const mountComponent = ({ props = { pipelinePath } } = {}) => {
+ const mountComponent = ({ props = { pipelinePath }, testFailureHistory = false } = {}) => {
wrapper = mount(Component, {
store: mockStore,
localVue,
@@ -29,6 +31,11 @@ describe('Grouped test reports app', () => {
pipelinePath,
...props,
},
+ provide: {
+ glFeatures: {
+ testFailureHistory,
+ },
+ },
});
};
@@ -39,6 +46,7 @@ describe('Grouped test reports app', () => {
};
const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]');
+ const findExpandButton = () => wrapper.find('[data-testid="report-section-expand-button"]');
const findFullTestReportLink = () => wrapper.find('[data-testid="group-test-reports-full-link"]');
const findSummaryDescription = () => wrapper.find('[data-testid="test-summary-row-description"]');
const findIssueDescription = () => wrapper.find('[data-testid="test-issue-body-description"]');
@@ -96,6 +104,35 @@ describe('Grouped test reports app', () => {
});
});
+ describe('`Expand` button', () => {
+ let trackingSpy;
+
+ beforeEach(() => {
+ setReports(newFailedTestReports);
+ mountComponent();
+ document.body.dataset.page = 'projects:merge_requests:show';
+ trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
+ });
+
+ it('tracks an event on click', () => {
+ findExpandButton().trigger('click');
+
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'expand_test_report_widget', {});
+ });
+
+ it('only tracks the first expansion', () => {
+ expect(trackingSpy).not.toHaveBeenCalled();
+
+ const button = findExpandButton();
+
+ button.trigger('click');
+ button.trigger('click');
+ button.trigger('click');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('with new failed result', () => {
beforeEach(() => {
setReports(newFailedTestReports);
@@ -203,6 +240,77 @@ describe('Grouped test reports app', () => {
});
});
+ describe('recent failures counts', () => {
+ describe('with recent failures counts', () => {
+ beforeEach(() => {
+ setReports(recentFailuresTestReports);
+ });
+
+ describe('with feature flag enabled', () => {
+ beforeEach(() => {
+ mountComponent({ testFailureHistory: true });
+ });
+
+ it('renders the recently failed tests summary', () => {
+ expect(findHeader().text()).toContain(
+ '2 out of 3 failed tests have failed more than once in the last 14 days',
+ );
+ });
+
+ it('renders the recently failed count on the test suite', () => {
+ expect(findSummaryDescription().text()).toContain(
+ '1 out of 2 failed tests has failed more than once in the last 14 days',
+ );
+ });
+
+ it('renders the recent failures count on the test case', () => {
+ expect(findIssueDescription().text()).toContain('Failed 8 times in the last 14 days');
+ });
+ });
+
+ describe('with feature flag disabled', () => {
+ beforeEach(() => {
+ mountComponent({ testFailureHistory: false });
+ });
+
+ it('does not render the recently failed tests summary', () => {
+ expect(findHeader().text()).not.toContain('failed more than once in the last 14 days');
+ });
+
+ it('does not render the recently failed count on the test suite', () => {
+ expect(findSummaryDescription().text()).not.toContain(
+ 'failed more than once in the last 14 days',
+ );
+ });
+
+ it('renders the recent failures count on the test case', () => {
+ expect(findIssueDescription().text()).not.toContain('in the last 14 days');
+ });
+ });
+ });
+
+ describe('without recent failures counts', () => {
+ beforeEach(() => {
+ setReports(mixedResultsTestReports);
+ mountComponent();
+ });
+
+ it('does not render the recently failed tests summary', () => {
+ expect(findHeader().text()).not.toContain('failed more than once in the last 14 days');
+ });
+
+ it('does not render the recently failed count on the test suite', () => {
+ expect(findSummaryDescription().text()).not.toContain(
+ 'failed more than once in the last 14 days',
+ );
+ });
+
+ it('does not render the recent failures count on the test case', () => {
+ expect(findIssueDescription().text()).not.toContain('in the last 14 days');
+ });
+ });
+ });
+
describe('with a report that failed to load', () => {
beforeEach(() => {
setReports(failedReport);
diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js
index a620b5d9afc..2d228313a9b 100644
--- a/spec/frontend/reports/components/report_section_spec.js
+++ b/spec/frontend/reports/components/report_section_spec.js
@@ -244,7 +244,7 @@ describe('Report section', () => {
hasIssues: true,
},
slots: {
- actionButtons: ['Action!'],
+ 'action-buttons': ['Action!'],
},
});
});
diff --git a/spec/frontend/reports/mock_data/recent_failures_report.json b/spec/frontend/reports/mock_data/recent_failures_report.json
new file mode 100644
index 00000000000..a47bc30a8e5
--- /dev/null
+++ b/spec/frontend/reports/mock_data/recent_failures_report.json
@@ -0,0 +1,46 @@
+{
+ "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 3, "recentlyFailed": 2 },
+ "suites": [
+ {
+ "name": "rspec:pg",
+ "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2, "recentlyFailed": 1 },
+ "new_failures": [
+ {
+ "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 <top (required)>'",
+ "recent_failures": 8
+ },
+ {
+ "result": "failure",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 0.000162,
+ "system_output": "Failure/Error: is_expected.to eq(300)\n\n expected: 300\n got: -100\n\n (compared using ==)\n./spec/test_spec.rb:21:in `block (4 levels) in <top (required)>'"
+ }
+ ],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ },
+ {
+ "name": "java ant",
+ "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1, "recentlyFailed": 1 },
+ "new_failures": [
+ {
+ "result": "failure",
+ "name": "Test#sum when a is 100 and b is 200 returns summary",
+ "execution_time": 0.000562,
+ "recent_failures": 3
+ }
+ ],
+ "resolved_failures": [],
+ "existing_failures": [],
+ "new_errors": [],
+ "resolved_errors": [],
+ "existing_errors": []
+ }
+ ]
+}
diff --git a/spec/frontend/reports/store/mutations_spec.js b/spec/frontend/reports/store/mutations_spec.js
index 9446cd454ab..82a399c876d 100644
--- a/spec/frontend/reports/store/mutations_spec.js
+++ b/spec/frontend/reports/store/mutations_spec.js
@@ -46,6 +46,7 @@ describe('Reports Store Mutations', () => {
name: 'StringHelper#concatenate when a is git and b is lab returns summary',
execution_time: 0.0092435,
system_output: "Failure/Error: is_expected.to eq('gitlab')",
+ recent_failures: 4,
},
],
resolved_failures: [
@@ -82,6 +83,7 @@ describe('Reports Store Mutations', () => {
expect(stateCopy.summary.total).toEqual(mockedResponse.summary.total);
expect(stateCopy.summary.resolved).toEqual(mockedResponse.summary.resolved);
expect(stateCopy.summary.failed).toEqual(mockedResponse.summary.failed);
+ expect(stateCopy.summary.recentlyFailed).toEqual(1);
});
it('should set reports', () => {
diff --git a/spec/frontend/reports/store/utils_spec.js b/spec/frontend/reports/store/utils_spec.js
index 9ae456658dc..8977268115e 100644
--- a/spec/frontend/reports/store/utils_spec.js
+++ b/spec/frontend/reports/store/utils_spec.js
@@ -168,6 +168,54 @@ describe('Reports store utils', () => {
});
});
+ describe('recentFailuresTextBuilder', () => {
+ it.each`
+ recentlyFailed | failed | expected
+ ${0} | ${1} | ${''}
+ ${1} | ${1} | ${'1 out of 1 failed test has failed more than once in the last 14 days'}
+ ${1} | ${2} | ${'1 out of 2 failed tests has failed more than once in the last 14 days'}
+ ${2} | ${3} | ${'2 out of 3 failed tests have failed more than once in the last 14 days'}
+ `(
+ 'should render summary for $recentlyFailed out of $failed failures',
+ ({ recentlyFailed, failed, expected }) => {
+ const result = utils.recentFailuresTextBuilder({ recentlyFailed, failed });
+
+ expect(result).toBe(expected);
+ },
+ );
+ });
+
+ describe('countRecentlyFailedTests', () => {
+ it('counts tests with more than one recent failure in a report', () => {
+ const report = {
+ new_failures: [{ recent_failures: 2 }],
+ existing_failures: [{ recent_failures: 1 }],
+ resolved_failures: [{ recent_failures: 20 }, { recent_failures: 5 }],
+ };
+ const result = utils.countRecentlyFailedTests(report);
+
+ expect(result).toBe(3);
+ });
+
+ it('counts tests with more than one recent failure in an array of reports', () => {
+ const reports = [
+ {
+ new_failures: [{ recent_failures: 2 }],
+ existing_failures: [{ recent_failures: 20 }, { recent_failures: 5 }],
+ resolved_failures: [{ recent_failures: 2 }],
+ },
+ {
+ new_failures: [{ recent_failures: 8 }, { recent_failures: 14 }],
+ existing_failures: [{ recent_failures: 1 }],
+ resolved_failures: [{ recent_failures: 7 }, { recent_failures: 5 }],
+ },
+ ];
+ const result = utils.countRecentlyFailedTests(reports);
+
+ expect(result).toBe(8);
+ });
+ });
+
describe('statusIcon', () => {
describe('with failed status', () => {
it('returns ICON_WARNING', () => {
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 aaa8bf168f2..be4f8a688e0 100644
--- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
+++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap
@@ -40,7 +40,6 @@ exports[`Repository last commit component renders commit widget 1`] = `
>
Test
-
</gl-link-stub>
authored
@@ -147,7 +146,6 @@ exports[`Repository last commit component renders the signature HTML as returned
>
Test
-
</gl-link-stub>
authored
diff --git a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
index 23c06dc5e68..e2ccc07d0f2 100644
--- a/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
+++ b/spec/frontend/repository/components/preview/__snapshots__/index_spec.js.snap
@@ -29,6 +29,7 @@ exports[`Repository file preview component renders file HTML 1`] = `
<div
class="blob-viewer"
data-qa-selector="blob_viewer_content"
+ itemprop="about"
>
<div>
<div
diff --git a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js b/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js
deleted file mode 100644
index 4a6b5cebe1c..00000000000
--- a/spec/frontend/search/dropdown_filter/components/dropdown_filter_spec.js
+++ /dev/null
@@ -1,196 +0,0 @@
-import Vuex from 'vuex';
-import { createLocalVue, shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
-import * as urlUtils from '~/lib/utils/url_utility';
-import initStore from '~/search/store';
-import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue';
-import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data';
-import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data';
-import { MOCK_QUERY } from '../mock_data';
-
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
- setUrlParams: jest.fn(),
-}));
-
-const localVue = createLocalVue();
-localVue.use(Vuex);
-
-describe('DropdownFilter', () => {
- let wrapper;
- let store;
-
- const createStore = options => {
- store = initStore({ query: MOCK_QUERY, ...options });
- };
-
- const createComponent = (props = { filterData: stateFilterData }) => {
- wrapper = shallowMount(DropdownFilter, {
- localVue,
- store,
- propsData: {
- ...props,
- },
- });
- };
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- store = null;
- });
-
- const findGlDropdown = () => wrapper.find(GlDropdown);
- const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
- const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
- const firstDropDownItem = () => findGlDropdownItems().at(0);
-
- describe('StatusFilter', () => {
- describe('template', () => {
- describe.each`
- scope | showDropdown
- ${'issues'} | ${true}
- ${'merge_requests'} | ${true}
- ${'projects'} | ${false}
- ${'milestones'} | ${false}
- ${'users'} | ${false}
- ${'notes'} | ${false}
- ${'wiki_blobs'} | ${false}
- ${'blobs'} | ${false}
- `(`dropdown`, ({ scope, showDropdown }) => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, scope } });
- createComponent();
- });
-
- it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showDropdown);
- });
- });
-
- describe.each`
- initialFilter | label
- ${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`}
- ${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label}
- ${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label}
- `(`filter text`, ({ initialFilter, label }) => {
- describe(`when initialFilter is ${initialFilter}`, () => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } });
- createComponent();
- });
-
- it(`sets dropdown label to ${label}`, () => {
- expect(findGlDropdown().attributes('text')).toBe(label);
- });
- });
- });
- });
-
- describe('Filter options', () => {
- beforeEach(() => {
- createStore();
- createComponent();
- });
-
- it('renders a dropdown item for each filterOption', () => {
- expect(findDropdownItemsText()).toStrictEqual(
- stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => {
- return v.label;
- }),
- );
- });
-
- it('clicking a dropdown item calls setUrlParams', () => {
- const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [stateFilterData.filterParam]: filter,
- });
- });
-
- it('clicking a dropdown item calls visitUrl', () => {
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- });
- });
- });
-
- describe('ConfidentialFilter', () => {
- describe('template', () => {
- describe.each`
- scope | showDropdown
- ${'issues'} | ${true}
- ${'merge_requests'} | ${false}
- ${'projects'} | ${false}
- ${'milestones'} | ${false}
- ${'users'} | ${false}
- ${'notes'} | ${false}
- ${'wiki_blobs'} | ${false}
- ${'blobs'} | ${false}
- `(`dropdown`, ({ scope, showDropdown }) => {
- beforeEach(() => {
- createStore({ query: { ...MOCK_QUERY, scope } });
- createComponent({ filterData: confidentialFilterData });
- });
-
- it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
- expect(findGlDropdown().exists()).toBe(showDropdown);
- });
- });
-
- describe.each`
- initialFilter | label
- ${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`}
- ${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label}
- ${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label}
- `(`filter text`, ({ initialFilter, label }) => {
- describe(`when initialFilter is ${initialFilter}`, () => {
- beforeEach(() => {
- createStore({
- query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter },
- });
- createComponent({ filterData: confidentialFilterData });
- });
-
- it(`sets dropdown label to ${label}`, () => {
- expect(findGlDropdown().attributes('text')).toBe(label);
- });
- });
- });
- });
- });
-
- describe('Filter options', () => {
- beforeEach(() => {
- createStore();
- createComponent({ filterData: confidentialFilterData });
- });
-
- it('renders a dropdown item for each filterOption', () => {
- expect(findDropdownItemsText()).toStrictEqual(
- confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => {
- return v.label;
- }),
- );
- });
-
- it('clicking a dropdown item calls setUrlParams', () => {
- const filter =
- confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [confidentialFilterData.filterParam]: filter,
- });
- });
-
- it('clicking a dropdown item calls visitUrl', () => {
- firstDropDownItem().vm.$emit('click');
-
- expect(urlUtils.visitUrl).toHaveBeenCalled();
- });
- });
-});
diff --git a/spec/frontend/search/dropdown_filter/mock_data.js b/spec/frontend/search/dropdown_filter/mock_data.js
deleted file mode 100644
index f11ab3d9951..00000000000
--- a/spec/frontend/search/dropdown_filter/mock_data.js
+++ /dev/null
@@ -1,5 +0,0 @@
-export const MOCK_QUERY = {
- scope: 'issues',
- state: 'all',
- confidential: null,
-};
diff --git a/spec/frontend/search/group_filter/components/group_filter_spec.js b/spec/frontend/search/group_filter/components/group_filter_spec.js
new file mode 100644
index 00000000000..fd3a4449f41
--- /dev/null
+++ b/spec/frontend/search/group_filter/components/group_filter_spec.js
@@ -0,0 +1,172 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
+import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
+import * as urlUtils from '~/lib/utils/url_utility';
+import GroupFilter from '~/search/group_filter/components/group_filter.vue';
+import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants';
+import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+describe('Global Search Group Filter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ fetchGroups: jest.fn(),
+ };
+
+ const defaultProps = {
+ initialGroup: null,
+ };
+
+ const createComponent = (initialState, props = {}, mountFn = shallowMount) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = mountFn(GroupFilter, {
+ localVue,
+ store,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlDropdown = () => wrapper.find(GlDropdown);
+ const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType);
+ const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
+ const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
+ const findDropdownItemsText = () => findDropdownItems().wrappers.map(w => w.text());
+ const findAnyDropdownItem = () => findDropdownItems().at(0);
+ const findFirstGroupDropdownItem = () => findDropdownItems().at(1);
+ const findLoader = () => wrapper.find(GlSkeletonLoader);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlDropdown', () => {
+ expect(findGlDropdown().exists()).toBe(true);
+ });
+
+ describe('findGlDropdownSearch', () => {
+ it('renders always', () => {
+ expect(findGlDropdownSearch().exists()).toBe(true);
+ });
+
+ it('has debounce prop', () => {
+ expect(findGlDropdownSearch().attributes('debounce')).toBe('500');
+ });
+
+ describe('onSearch', () => {
+ const groupSearch = 'test search';
+
+ beforeEach(() => {
+ findGlDropdownSearch().vm.$emit('input', groupSearch);
+ });
+
+ it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => {
+ expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch);
+ });
+ });
+ });
+
+ describe('findDropdownItems', () => {
+ describe('when fetchingGroups is false', () => {
+ beforeEach(() => {
+ createComponent({ groups: MOCK_GROUPS });
+ });
+
+ it('does not render loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('renders an instance for each namespace', () => {
+ const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
+ expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny);
+ });
+ });
+
+ describe('when fetchingGroups is true', () => {
+ beforeEach(() => {
+ createComponent({ fetchingGroups: true, groups: MOCK_GROUPS });
+ });
+
+ it('does render loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('renders only Any in dropdown', () => {
+ expect(findDropdownItemsText()).toStrictEqual(['Any']);
+ });
+ });
+ });
+
+ describe('Dropdown Text', () => {
+ describe('when initialGroup is null', () => {
+ beforeEach(() => {
+ createComponent({}, {}, mount);
+ });
+
+ it('sets dropdown text to Any', () => {
+ expect(findDropdownText().text()).toBe(ANY_GROUP.name);
+ });
+ });
+
+ describe('initialGroup is set', () => {
+ beforeEach(() => {
+ createComponent({}, { initialGroup: MOCK_GROUP }, mount);
+ });
+
+ it('sets dropdown text to group name', () => {
+ expect(findDropdownText().text()).toBe(MOCK_GROUP.name);
+ });
+ });
+ });
+ });
+
+ describe('actions', () => {
+ beforeEach(() => {
+ createComponent({ groups: MOCK_GROUPS });
+ });
+
+ it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => {
+ findAnyDropdownItem().vm.$emit('click');
+
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
+ [GROUP_QUERY_PARAM]: ANY_GROUP.id,
+ [PROJECT_QUERY_PARAM]: null,
+ });
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+
+ it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => {
+ findFirstGroupDropdownItem().vm.$emit('click');
+
+ expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
+ [GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
+ [PROJECT_QUERY_PARAM]: null,
+ });
+ expect(urlUtils.visitUrl).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/pages/search/show/highlight_blob_search_result_spec.js b/spec/frontend/search/highlight_blob_search_result_spec.js
index 4083a65df75..112e6f5124f 100644
--- a/spec/frontend/pages/search/show/highlight_blob_search_result_spec.js
+++ b/spec/frontend/search/highlight_blob_search_result_spec.js
@@ -1,8 +1,8 @@
-import setHighlightClass from '~/pages/search/show/highlight_blob_search_result';
+import setHighlightClass from '~/search/highlight_blob_search_result';
const fixture = 'search/blob_search_result.html';
-describe('pages/search/show/highlight_blob_search_result', () => {
+describe('search/highlight_blob_search_result', () => {
preloadFixtures(fixture);
beforeEach(() => loadFixtures(fixture));
@@ -10,6 +10,6 @@ describe('pages/search/show/highlight_blob_search_result', () => {
it('highlights lines with search term occurrence', () => {
setHighlightClass();
- expect(document.querySelectorAll('.blob-result .hll').length).toBe(11);
+ expect(document.querySelectorAll('.blob-result .hll').length).toBe(4);
});
});
diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js
new file mode 100644
index 00000000000..8a86cc4c52a
--- /dev/null
+++ b/spec/frontend/search/index_spec.js
@@ -0,0 +1,47 @@
+import { initSearchApp } from '~/search';
+import createStore from '~/search/store';
+
+jest.mock('~/search/store');
+jest.mock('~/search/sidebar');
+jest.mock('~/search/group_filter');
+
+describe('initSearchApp', () => {
+ let defaultLocation;
+
+ const setUrl = query => {
+ window.location.href = `https://localhost:3000/search${query}`;
+ window.location.search = query;
+ };
+
+ beforeEach(() => {
+ defaultLocation = window.location;
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: '', search: '' },
+ });
+ });
+
+ afterEach(() => {
+ window.location = defaultLocation;
+ });
+
+ describe.each`
+ search | decodedSearch
+ ${'test'} | ${'test'}
+ ${'%2520'} | ${'%20'}
+ ${'test%2Bthis%2Bstuff'} | ${'test+this+stuff'}
+ ${'test+this+stuff'} | ${'test this stuff'}
+ ${'test+%2B+this+%2B+stuff'} | ${'test + this + stuff'}
+ ${'test%2B+%2Bthis%2B+%2Bstuff'} | ${'test+ +this+ +stuff'}
+ ${'test+%2520+this+%2520+stuff'} | ${'test %20 this %20 stuff'}
+ `('parameter decoding', ({ search, decodedSearch }) => {
+ beforeEach(() => {
+ setUrl(`?search=${search}`);
+ initSearchApp();
+ });
+
+ it(`decodes ${search} to ${decodedSearch}`, () => {
+ expect(createStore).toHaveBeenCalledWith({ query: { search: decodedSearch } });
+ });
+ });
+});
diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js
new file mode 100644
index 00000000000..68fc432881a
--- /dev/null
+++ b/spec/frontend/search/mock_data.js
@@ -0,0 +1,24 @@
+export const MOCK_QUERY = {
+ scope: 'issues',
+ state: 'all',
+ confidential: null,
+};
+
+export const MOCK_GROUP = {
+ name: 'test group',
+ full_name: 'full name test group',
+ id: 'test_1',
+};
+
+export const MOCK_GROUPS = [
+ {
+ name: 'test group',
+ full_name: 'full name test group',
+ id: 'test_1',
+ },
+ {
+ name: 'test group 2',
+ full_name: 'full name test group 2',
+ id: 'test_2',
+ },
+];
diff --git a/spec/frontend/search/sidebar/components/app_spec.js b/spec/frontend/search/sidebar/components/app_spec.js
new file mode 100644
index 00000000000..d2c0081080c
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/app_spec.js
@@ -0,0 +1,103 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlButton, GlLink } from '@gitlab/ui';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
+import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
+import StatusFilter from '~/search/sidebar/components/status_filter.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('GlobalSearchSidebar', () => {
+ let wrapper;
+
+ const actionSpies = {
+ applyQuery: jest.fn(),
+ resetQuery: jest.fn(),
+ };
+
+ const createComponent = initialState => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(GlobalSearchSidebar, {
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSidebarForm = () => wrapper.find('form');
+ const findStatusFilter = () => wrapper.find(StatusFilter);
+ const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter);
+ const findApplyButton = () => wrapper.find(GlButton);
+ const findResetLinkButton = () => wrapper.find(GlLink);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders StatusFilter always', () => {
+ expect(findStatusFilter().exists()).toBe(true);
+ });
+
+ it('renders ConfidentialityFilter always', () => {
+ expect(findConfidentialityFilter().exists()).toBe(true);
+ });
+
+ it('renders ApplyButton always', () => {
+ expect(findApplyButton().exists()).toBe(true);
+ });
+ });
+
+ describe('ResetLinkButton', () => {
+ describe('with no filter selected', () => {
+ beforeEach(() => {
+ createComponent({ query: {} });
+ });
+
+ it('does not render', () => {
+ expect(findResetLinkButton().exists()).toBe(false);
+ });
+ });
+
+ describe('with filter selected', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('does render when a filter selected', () => {
+ expect(findResetLinkButton().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('actions', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('clicking ApplyButton calls applyQuery', () => {
+ findSidebarForm().trigger('submit');
+
+ expect(actionSpies.applyQuery).toHaveBeenCalled();
+ });
+
+ it('clicking ResetLinkButton calls resetQuery', () => {
+ findResetLinkButton().vm.$emit('click');
+
+ expect(actionSpies.resetQuery).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
new file mode 100644
index 00000000000..68d20b2480e
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js
@@ -0,0 +1,65 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
+import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ConfidentialityFilter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ applyQuery: jest.fn(),
+ resetQuery: jest.fn(),
+ };
+
+ const createComponent = initialState => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(ConfidentialityFilter, {
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findRadioFilter = () => wrapper.find(RadioFilter);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe.each`
+ scope | showFilter
+ ${'issues'} | ${true}
+ ${'merge_requests'} | ${false}
+ ${'projects'} | ${false}
+ ${'milestones'} | ${false}
+ ${'users'} | ${false}
+ ${'notes'} | ${false}
+ ${'wiki_blobs'} | ${false}
+ ${'blobs'} | ${false}
+ `(`dropdown`, ({ scope, showFilter }) => {
+ beforeEach(() => {
+ createComponent({ query: { scope } });
+ });
+
+ it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
+ expect(findRadioFilter().exists()).toBe(showFilter);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/radio_filter_spec.js b/spec/frontend/search/sidebar/components/radio_filter_spec.js
new file mode 100644
index 00000000000..31a4a8859ee
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/radio_filter_spec.js
@@ -0,0 +1,111 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
+import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
+import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('RadioFilter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ setQuery: jest.fn(),
+ };
+
+ const defaultProps = {
+ filterData: stateFilterData,
+ };
+
+ const createComponent = (initialState, props = {}) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(RadioFilter, {
+ localVue,
+ store,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findGlRadioButtonGroup = () => wrapper.find(GlFormRadioGroup);
+ const findGlRadioButtons = () => findGlRadioButtonGroup().findAll(GlFormRadio);
+ const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map(w => w.text());
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders GlRadioButtonGroup always', () => {
+ expect(findGlRadioButtonGroup().exists()).toBe(true);
+ });
+
+ describe('Radio Buttons', () => {
+ describe('Status Filter', () => {
+ it('renders a radio button for each filterOption', () => {
+ expect(findGlRadioButtonsText()).toStrictEqual(
+ stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(f => {
+ return f.value === stateFilterData.filters.ANY.value
+ ? `Any ${stateFilterData.header.toLowerCase()}`
+ : f.label;
+ }),
+ );
+ });
+
+ it('clicking a radio button item calls setQuery', () => {
+ const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
+ findGlRadioButtonGroup().vm.$emit('input', filter);
+
+ expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
+ key: stateFilterData.filterParam,
+ value: filter,
+ });
+ });
+ });
+
+ describe('Confidentiality Filter', () => {
+ beforeEach(() => {
+ createComponent({}, { filterData: confidentialFilterData });
+ });
+
+ it('renders a radio button for each filterOption', () => {
+ expect(findGlRadioButtonsText()).toStrictEqual(
+ confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(f => {
+ return f.value === confidentialFilterData.filters.ANY.value
+ ? `Any ${confidentialFilterData.header.toLowerCase()}`
+ : f.label;
+ }),
+ );
+ });
+
+ it('clicking a radio button item calls setQuery', () => {
+ const filter =
+ confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
+ findGlRadioButtonGroup().vm.$emit('input', filter);
+
+ expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
+ key: confidentialFilterData.filterParam,
+ value: filter,
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js
new file mode 100644
index 00000000000..188d47b38cd
--- /dev/null
+++ b/spec/frontend/search/sidebar/components/status_filter_spec.js
@@ -0,0 +1,65 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { MOCK_QUERY } from 'jest/search/mock_data';
+import StatusFilter from '~/search/sidebar/components/status_filter.vue';
+import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('StatusFilter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ applyQuery: jest.fn(),
+ resetQuery: jest.fn(),
+ };
+
+ const createComponent = initialState => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(StatusFilter, {
+ localVue,
+ store,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findRadioFilter = () => wrapper.find(RadioFilter);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ describe.each`
+ scope | showFilter
+ ${'issues'} | ${true}
+ ${'merge_requests'} | ${true}
+ ${'projects'} | ${false}
+ ${'milestones'} | ${false}
+ ${'users'} | ${false}
+ ${'notes'} | ${false}
+ ${'wiki_blobs'} | ${false}
+ ${'blobs'} | ${false}
+ `(`dropdown`, ({ scope, showFilter }) => {
+ beforeEach(() => {
+ createComponent({ query: { scope } });
+ });
+
+ it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
+ expect(findRadioFilter().exists()).toBe(showFilter);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/store/actions_spec.js b/spec/frontend/search/store/actions_spec.js
new file mode 100644
index 00000000000..c8ea6167399
--- /dev/null
+++ b/spec/frontend/search/store/actions_spec.js
@@ -0,0 +1,90 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import * as actions from '~/search/store/actions';
+import * as types from '~/search/store/mutation_types';
+import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
+import state from '~/search/store/state';
+import axios from '~/lib/utils/axios_utils';
+import createFlash from '~/flash';
+import { MOCK_GROUPS } from '../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ setUrlParams: jest.fn(),
+ visitUrl: jest.fn(),
+ joinPaths: jest.fn(), // For the axios specs
+}));
+
+describe('Global Search Store Actions', () => {
+ let mock;
+
+ const noCallback = () => {};
+ const flashCallback = () => {
+ expect(createFlash).toHaveBeenCalledTimes(1);
+ createFlash.mockClear();
+ };
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe.each`
+ action | axiosMock | type | mutationCalls | callback
+ ${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback}
+ ${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback}
+ `(`axios calls`, ({ action, axiosMock, type, mutationCalls, callback }) => {
+ describe(action.name, () => {
+ describe(`on ${type}`, () => {
+ beforeEach(() => {
+ mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
+ });
+ it(`should dispatch the correct mutations`, () => {
+ return testAction(action, null, state, mutationCalls, []).then(() => callback());
+ });
+ });
+ });
+ });
+
+ describe('setQuery', () => {
+ const payload = { key: 'key1', value: 'value1' };
+
+ it('calls the SET_QUERY mutation', done => {
+ testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
+ });
+ });
+
+ describe('applyQuery', () => {
+ it('calls visitUrl and setParams with the state.query', () => {
+ testAction(actions.applyQuery, null, state, [], [], () => {
+ expect(setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null });
+ expect(visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('resetQuery', () => {
+ it('calls visitUrl and setParams with empty values', () => {
+ testAction(actions.resetQuery, null, state, [], [], () => {
+ expect(setUrlParams).toHaveBeenCalledWith({
+ ...state.query,
+ page: null,
+ state: null,
+ confidential: null,
+ });
+ expect(visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+});
+
+describe('setQuery', () => {
+ const payload = { key: 'key1', value: 'value1' };
+
+ it('calls the SET_QUERY mutation', done => {
+ testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
+ });
+});
diff --git a/spec/frontend/search/store/mutations_spec.js b/spec/frontend/search/store/mutations_spec.js
new file mode 100644
index 00000000000..28d9646b97e
--- /dev/null
+++ b/spec/frontend/search/store/mutations_spec.js
@@ -0,0 +1,48 @@
+import mutations from '~/search/store/mutations';
+import createState from '~/search/store/state';
+import * as types from '~/search/store/mutation_types';
+import { MOCK_QUERY, MOCK_GROUPS } from '../mock_data';
+
+describe('Global Search Store Mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = createState({ query: MOCK_QUERY });
+ });
+
+ describe('REQUEST_GROUPS', () => {
+ it('sets fetchingGroups to true', () => {
+ mutations[types.REQUEST_GROUPS](state);
+
+ expect(state.fetchingGroups).toBe(true);
+ });
+ });
+
+ describe('RECEIVE_GROUPS_SUCCESS', () => {
+ it('sets fetchingGroups to false and sets groups', () => {
+ mutations[types.RECEIVE_GROUPS_SUCCESS](state, MOCK_GROUPS);
+
+ expect(state.fetchingGroups).toBe(false);
+ expect(state.groups).toBe(MOCK_GROUPS);
+ });
+ });
+
+ describe('RECEIVE_GROUPS_ERROR', () => {
+ it('sets fetchingGroups to false and clears groups', () => {
+ mutations[types.RECEIVE_GROUPS_ERROR](state);
+
+ expect(state.fetchingGroups).toBe(false);
+ expect(state.groups).toEqual([]);
+ });
+ });
+
+ describe('SET_QUERY', () => {
+ const payload = { key: 'key1', value: 'value1' };
+
+ it('sets query key to value', () => {
+ mutations[types.SET_QUERY](state, payload);
+
+ expect(state.query[payload.key]).toBe(payload.value);
+ });
+ });
+});
diff --git a/spec/frontend/search_spec.js b/spec/frontend/search_spec.js
index 1573365538c..cbbc2df6c78 100644
--- a/spec/frontend/search_spec.js
+++ b/spec/frontend/search_spec.js
@@ -1,10 +1,10 @@
import $ from 'jquery';
+import setHighlightClass from 'ee_else_ce/search/highlight_blob_search_result';
import Api from '~/api';
import Search from '~/pages/search/show/search';
-import setHighlightClass from '~/pages/search/show/highlight_blob_search_result';
jest.mock('~/api');
-jest.mock('~/pages/search/show/highlight_blob_search_result');
+jest.mock('ee_else_ce/search/highlight_blob_search_result');
describe('Search', () => {
const fixturePath = 'search/show.html';
@@ -36,16 +36,6 @@ describe('Search', () => {
new Search(); // eslint-disable-line no-new
});
- it('requests groups from backend when filtering', () => {
- jest.spyOn(Api, 'groups').mockImplementation(term => {
- expect(term).toBe(searchTerm);
- });
-
- const inputElement = fillDropdownInput('.js-search-group-dropdown');
-
- $(inputElement).trigger('input');
- });
-
it('requests projects from backend when filtering', () => {
jest.spyOn(Api, 'projects').mockImplementation(term => {
expect(term).toBe(searchTerm);
diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
new file mode 100644
index 00000000000..fad23aa05a4
--- /dev/null
+++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js
@@ -0,0 +1,257 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal, GlFormCheckbox } from '@gitlab/ui';
+import { initEmojiMock } from 'helpers/emoji';
+import Api from '~/api';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import SetStatusModalWrapper, {
+ AVAILABILITY_STATUS,
+} from '~/set_status_modal/set_status_modal_wrapper.vue';
+
+jest.mock('~/api');
+jest.mock('~/flash');
+
+describe('SetStatusModalWrapper', () => {
+ let wrapper;
+ let mockEmoji;
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const defaultEmoji = 'speech_balloon';
+ const defaultMessage = "They're comin' in too fast!";
+
+ const defaultProps = {
+ currentEmoji: defaultEmoji,
+ currentMessage: defaultMessage,
+ defaultEmoji,
+ canSetUserAvailability: true,
+ };
+
+ const createComponent = (props = {}) => {
+ return shallowMount(SetStatusModalWrapper, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ mocks: {
+ $toast,
+ },
+ });
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findFormField = field => wrapper.find(`[name="user[status][${field}]"]`);
+ const findClearStatusButton = () => wrapper.find('.js-clear-user-status-button');
+ const findNoEmojiPlaceholder = () => wrapper.find('.js-no-emoji-placeholder');
+ const findToggleEmojiButton = () => wrapper.find('.js-toggle-emoji-menu');
+ const findAvailabilityCheckbox = () => wrapper.find(GlFormCheckbox);
+
+ const initModal = ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => {
+ const modal = findModal();
+ // mock internal emoji methods
+ wrapper.vm.showEmojiMenu = jest.fn();
+ wrapper.vm.hideEmojiMenu = jest.fn();
+ if (mockOnUpdateSuccess) wrapper.vm.onUpdateSuccess = jest.fn();
+ if (mockOnUpdateFailure) wrapper.vm.onUpdateFail = jest.fn();
+
+ modal.vm.$emit('shown');
+ return wrapper.vm.$nextTick();
+ };
+
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent();
+ return initModal();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mockEmoji.restore();
+ });
+
+ describe('with minimum props', () => {
+ it('sets the hidden status emoji field', () => {
+ const field = findFormField('emoji');
+ expect(field.exists()).toBe(true);
+ expect(field.element.value).toBe(defaultEmoji);
+ });
+
+ it('sets the message field', () => {
+ const field = findFormField('message');
+ expect(field.exists()).toBe(true);
+ expect(field.element.value).toBe(defaultMessage);
+ });
+
+ it('sets the availability field to false', () => {
+ const field = findAvailabilityCheckbox();
+ expect(field.exists()).toBe(true);
+ expect(field.element.checked).toBeUndefined();
+ });
+
+ it('has a clear status button', () => {
+ expect(findClearStatusButton().isVisible()).toBe(true);
+ });
+
+ it('clicking the toggle emoji button displays the emoji list', () => {
+ expect(wrapper.vm.showEmojiMenu).not.toHaveBeenCalled();
+ findToggleEmojiButton().trigger('click');
+ expect(wrapper.vm.showEmojiMenu).toHaveBeenCalled();
+ });
+ });
+
+ describe('with no currentMessage set', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ currentMessage: '' });
+ return initModal();
+ });
+
+ it('does not set the message field', () => {
+ expect(findFormField('message').element.value).toBe('');
+ });
+
+ it('hides the clear status button', () => {
+ expect(findClearStatusButton().isVisible()).toBe(false);
+ });
+
+ it('shows the placeholder emoji', () => {
+ expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
+ });
+ });
+
+ describe('with no currentEmoji set', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ currentEmoji: '' });
+ return initModal();
+ });
+
+ it('does not set the hidden status emoji field', () => {
+ expect(findFormField('emoji').element.value).toBe('');
+ });
+
+ it('hides the placeholder emoji', () => {
+ expect(findNoEmojiPlaceholder().isVisible()).toBe(false);
+ });
+
+ describe('with no currentMessage set', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
+ return initModal();
+ });
+
+ it('shows the placeholder emoji', () => {
+ expect(findNoEmojiPlaceholder().isVisible()).toBe(true);
+ });
+ });
+ });
+
+ describe('update status', () => {
+ describe('succeeds', () => {
+ beforeEach(() => {
+ jest.spyOn(Api, 'postUserStatus').mockResolvedValue();
+ });
+
+ it('clicking "removeStatus" clears the emoji and message fields', async () => {
+ findModal().vm.$emit('cancel');
+ await wrapper.vm.$nextTick();
+
+ expect(findFormField('message').element.value).toBe('');
+ expect(findFormField('emoji').element.value).toBe('');
+ });
+
+ it('clicking "setStatus" submits the user status', async () => {
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ // set the availability status
+ findAvailabilityCheckbox().vm.$emit('input', true);
+
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ const commonParams = { emoji: defaultEmoji, message: defaultMessage };
+
+ expect(Api.postUserStatus).toHaveBeenCalledTimes(2);
+ expect(Api.postUserStatus).toHaveBeenNthCalledWith(1, {
+ availability: AVAILABILITY_STATUS.NOT_SET,
+ ...commonParams,
+ });
+ expect(Api.postUserStatus).toHaveBeenNthCalledWith(2, {
+ availability: AVAILABILITY_STATUS.BUSY,
+ ...commonParams,
+ });
+ });
+
+ it('calls the "onUpdateSuccess" handler', async () => {
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.onUpdateSuccess).toHaveBeenCalled();
+ });
+ });
+
+ describe('success message', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
+ jest.spyOn(Api, 'postUserStatus').mockResolvedValue();
+ return initModal({ mockOnUpdateSuccess: false });
+ });
+
+ it('displays a toast success message', async () => {
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ expect($toast.show).toHaveBeenCalledWith('Status updated', {
+ position: 'top-center',
+ type: 'success',
+ });
+ });
+ });
+
+ describe('with errors', () => {
+ beforeEach(() => {
+ jest.spyOn(Api, 'postUserStatus').mockRejectedValue();
+ });
+
+ it('calls the "onUpdateFail" handler', async () => {
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.onUpdateFail).toHaveBeenCalled();
+ });
+ });
+
+ describe('error message', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ currentEmoji: '', currentMessage: '' });
+ jest.spyOn(Api, 'postUserStatus').mockRejectedValue();
+ return initModal({ mockOnUpdateFailure: false });
+ });
+
+ it('flashes an error message', async () => {
+ findModal().vm.$emit('ok');
+ await wrapper.vm.$nextTick();
+
+ expect(createFlash).toHaveBeenCalledWith(
+ "Sorry, we weren't able to set your status. Please try again later.",
+ );
+ });
+ });
+ });
+
+ describe('with canSetUserAvailability=false', () => {
+ beforeEach(async () => {
+ mockEmoji = await initEmojiMock();
+ wrapper = createComponent({ canSetUserAvailability: false });
+ return initModal();
+ });
+
+ it('hides the set availability checkbox', () => {
+ expect(findAvailabilityCheckbox().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/set_status_modal/user_availability_status_spec.js b/spec/frontend/set_status_modal/user_availability_status_spec.js
new file mode 100644
index 00000000000..95ca0251ce0
--- /dev/null
+++ b/spec/frontend/set_status_modal/user_availability_status_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
+import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
+
+describe('UserAvailabilityStatus', () => {
+ let wrapper;
+
+ const createComponent = (props = {}) => {
+ return shallowMount(UserAvailabilityStatus, {
+ propsData: {
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('with availability status', () => {
+ it(`set to ${AVAILABILITY_STATUS.BUSY}`, () => {
+ wrapper = createComponent({ availability: AVAILABILITY_STATUS.BUSY });
+ expect(wrapper.text()).toContain('(Busy)');
+ });
+
+ it(`set to ${AVAILABILITY_STATUS.NOT_SET}`, () => {
+ wrapper = createComponent({ availability: AVAILABILITY_STATUS.NOT_SET });
+ expect(wrapper.html()).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
index 42012841f0b..6640c0844e2 100644
--- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
+++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap
@@ -27,7 +27,7 @@ exports[`SidebarTodo template renders component container element with proper da
</span>
<gl-loading-icon-stub
- color="orange"
+ color="dark"
inline="true"
label="Loading"
size="sm"
diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
index 5307be0bf58..bcd2c14f2fa 100644
--- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
+++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js
@@ -1,277 +1,226 @@
-import Vue from 'vue';
-
-import mountComponent from 'helpers/vue_mount_component_helper';
+import { createMockDirective } from 'helpers/vue_mock_directive';
+import { mount } from '@vue/test-utils';
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');
+ let wrapper;
+
+ const findByTestId = testId => wrapper.find(`[data-testid=${testId}]`);
+ const findComparisonMeter = () => findByTestId('compareMeter').attributes('title');
+ const findCollapsedState = () => findByTestId('collapsedState');
+ const findTimeRemainingProgress = () => findByTestId('timeRemainingProgress');
+
+ const defaultProps = {
+ timeEstimate: 10_000, // 2h 46m
+ timeSpent: 5_000, // 1h 23m
+ humanTimeEstimate: '2h 46m',
+ humanTimeSpent: '1h 23m',
+ limitToHours: false,
};
+ const mountComponent = ({ props = {} } = {}) =>
+ mount(TimeTracker, {
+ propsData: { ...defaultProps, ...props },
+ directives: { GlTooltip: createMockDirective() },
+ });
+
afterEach(() => {
- vm.$destroy();
+ wrapper.destroy();
});
describe('Initialization', () => {
beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 10000, // 2h 46m
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '2h 46m',
- timeSpentHumanReadable: '1h 23m',
- });
+ wrapper = mountComponent();
});
it('should return something defined', () => {
- expect(vm).toBeDefined();
+ expect(wrapper).toBeDefined();
});
- it('should correctly set timeEstimate', done => {
- Vue.nextTick(() => {
- expect(vm.timeEstimate).toBe(initialData.timeEstimate);
- done();
- });
+ it('should correctly render timeEstimate', () => {
+ expect(findByTestId('timeTrackingComparisonPane').html()).toContain(
+ defaultProps.humanTimeEstimate,
+ );
});
- it('should correctly set time_spent', done => {
- Vue.nextTick(() => {
- expect(vm.timeSpent).toBe(initialData.timeSpent);
- done();
- });
+ it('should correctly render time_spent', () => {
+ expect(findByTestId('timeTrackingComparisonPane').html()).toContain(
+ defaultProps.humanTimeSpent,
+ );
});
});
- describe('Content Display', () => {
- describe('Panes', () => {
- describe('Comparison pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 100000, // 1d 3h
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '1d 3h',
- timeSpentHumanReadable: '1h 23m',
- });
+ describe('Content panes', () => {
+ describe('Collapsed state', () => {
+ it('should render "time-tracking-collapsed-state" by default when "showCollapsed" prop is not specified', () => {
+ wrapper = mountComponent();
+
+ expect(findCollapsedState().exists()).toBe(true);
+ });
+
+ it('should not render "time-tracking-collapsed-state" when "showCollapsed" is false', () => {
+ wrapper = mountComponent({
+ props: {
+ showCollapsed: false,
+ },
});
- 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(findCollapsedState().exists()).toBe(false);
+ });
+ });
- expect($comparisonPane).toBeVisible();
- done();
- });
+ describe('Comparison pane', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
+ timeEstimate: 100_000, // 1d 3h
+ timeSpent: 5_000, // 1h 23m
+ humanTimeEstimate: '1d 3h',
+ humanTimeSpent: '1h 23m',
+ },
});
+ });
- it('should show full times when the sidebar is collapsed', done => {
- Vue.nextTick(() => {
- const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span')
- .textContent;
+ it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', () => {
+ const pane = findByTestId('timeTrackingComparisonPane');
+ expect(pane.exists()).toBe(true);
+ expect(pane.isVisible()).toBe(true);
+ });
- expect(timeTrackingText.trim()).toBe('1h 23m / 1d 3h');
- done();
- });
+ it('should show full times when the sidebar is collapsed', () => {
+ expect(findCollapsedState().text()).toBe('1h 23m / 1d 3h');
+ });
+
+ describe('Remaining meter', () => {
+ it('should display the remaining meter with the correct width', () => {
+ expect(findTimeRemainingProgress().attributes('value')).toBe('5');
});
- 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', () => {
+ expect(findTimeRemainingProgress().attributes('variant')).toBe('primary');
+ });
- 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', () => {
+ wrapper = mountComponent({
+ props: {
+ timeEstimate: 10_000, // 2h 46m
+ timeSpent: 20_000_000, // 231 days
+ },
});
- 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();
- });
- });
+ expect(findTimeRemainingProgress().attributes('variant')).toBe('danger');
});
});
+ });
- describe('Comparison pane when limitToHours is true', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 100000, // 1d 3h
- timeSpent: 5000, // 1h 23m
- timeEstimateHumanReadable: '',
- timeSpentHumanReadable: '',
+ describe('Comparison pane when limitToHours is true', () => {
+ beforeEach(async () => {
+ wrapper = mountComponent({
+ props: {
+ timeEstimate: 100_000, // 1d 3h
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').title;
+ it('should show the correct tooltip text', async () => {
+ expect(findByTestId('timeTrackingComparisonPane').exists()).toBe(true);
+ await wrapper.vm.$nextTick();
- expect($title).toBe('Time remaining: 26h 23m');
- done();
- });
- });
+ expect(findComparisonMeter()).toBe('Time remaining: 26h 23m');
});
+ });
- describe('Estimate only pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
- timeEstimate: 10000, // 2h 46m
+ describe('Estimate only pane', () => {
+ beforeEach(async () => {
+ wrapper = mountComponent({
+ props: {
+ timeEstimate: 10_000, // 2h 46m
timeSpent: 0,
timeEstimateHumanReadable: '2h 46m',
timeSpentHumanReadable: '',
- });
+ },
});
+ await wrapper.vm.$nextTick();
+ });
- 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();
- });
- });
+ it('should display the human readable version of time estimated', () => {
+ const estimateText = findByTestId('estimateOnlyPane').text();
+ expect(estimateText.trim()).toBe('Estimated: 2h 46m');
});
+ });
- describe('Spent only pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
+ describe('Spent only pane', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
timeEstimate: 0,
- timeSpent: 5000, // 1h 23m
+ timeSpent: 5_000, // 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();
- });
- });
+ it('should display the human readable version of time spent', () => {
+ const spentText = findByTestId('spentOnlyPane').text();
+ expect(spentText.trim()).toBe('Spent: 1h 23m');
});
+ });
- describe('No time tracking pane', () => {
- beforeEach(() => {
- initTimeTrackingComponent({
+ describe('No time tracking pane', () => {
+ beforeEach(() => {
+ wrapper = mountComponent({
+ props: {
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();
- });
- });
+ it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', () => {
+ const pane = findByTestId('noTrackingPane');
+ const correctText = 'No estimate or time spent';
+ expect(pane.exists()).toBe(true);
+ expect(pane.text().trim()).toBe(correctText);
});
+ });
+
+ describe('Help pane', () => {
+ const findHelpButton = () => findByTestId('helpButton');
+ const findCloseHelpButton = () => findByTestId('closeHelpButton');
- 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(async () => {
+ wrapper = mountComponent({ props: { timeEstimate: 0, timeSpent: 0 } });
+ await wrapper.vm.$nextTick();
+ });
- beforeEach(() => {
- initTimeTrackingComponent({ timeEstimate: 0, timeSpent: 0 });
+ it('should not show the "Help" pane by default', () => {
+ expect(findByTestId('helpPane').exists()).toBe(false);
+ });
- return vm.$nextTick();
- });
+ it('should show the "Help" pane when help button is clicked', async () => {
+ findHelpButton().trigger('click');
- it('should not show the "Help" pane by default', () => {
- expect(vm.showHelpState).toBe(false);
- expect(helpPane()).toBeNull();
- });
+ await wrapper.vm.$nextTick();
- it('should show the "Help" pane when help button is clicked', () => {
- helpButton().click();
+ expect(findByTestId('helpPane').exists()).toBe(true);
+ });
- return vm.$nextTick().then(() => {
- expect(vm.showHelpState).toBe(true);
+ it('should not show the "Help" pane when help button is clicked and then closed', async () => {
+ findHelpButton().trigger('click');
+ await wrapper.vm.$nextTick();
- // let animations run
- jest.advanceTimersByTime(500);
+ expect(findByTestId('helpPane').classes('help-state-toggle-enter')).toBe(true);
+ expect(findByTestId('helpPane').classes('help-state-toggle-leave')).toBe(false);
- expect(helpPane()).toBeVisible();
- });
- });
+ findCloseHelpButton().trigger('click');
+ await wrapper.vm.$nextTick();
- 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);
- });
+ expect(findByTestId('helpPane').classes('help-state-toggle-leave')).toBe(true);
+ expect(findByTestId('helpPane').classes('help-state-toggle-enter')).toBe(false);
});
});
});
diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js
index aa930bd4198..076616de040 100644
--- a/spec/frontend/sidebar/issuable_assignees_spec.js
+++ b/spec/frontend/sidebar/issuable_assignees_spec.js
@@ -13,7 +13,6 @@ describe('IssuableAssignees', () => {
propsData: { ...props },
});
};
- const findLabel = () => wrapper.find('[data-testid="assigneeLabel"');
const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList);
const findEmptyAssignee = () => wrapper.find('[data-testid="none"]');
@@ -30,10 +29,6 @@ describe('IssuableAssignees', () => {
it('renders "None"', () => {
expect(findEmptyAssignee().text()).toBe('None');
});
-
- it('renders "0 assignees"', () => {
- expect(findLabel().text()).toBe('0 Assignees');
- });
});
describe('when assignees are present', () => {
@@ -42,18 +37,5 @@ describe('IssuableAssignees', () => {
expect(findUncollapsedAssigneeList().exists()).toBe(true);
});
-
- it.each`
- assignees | expected
- ${[{ id: 1 }]} | ${'Assignee'}
- ${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'}
- `(
- 'when assignees have a length of $assignees.length, it renders $expected',
- ({ assignees, expected }) => {
- createComponent({ users: assignees });
-
- expect(findLabel().text()).toBe(expected);
- },
- );
});
});
diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js
index 7a687ffa761..36d1e129b6a 100644
--- a/spec/frontend/sidebar/sidebar_labels_spec.js
+++ b/spec/frontend/sidebar/sidebar_labels_spec.js
@@ -1,16 +1,18 @@
import { shallowMount } from '@vue/test-utils';
-import AxiosMockAdapter from 'axios-mock-adapter';
import {
mockLabels,
mockRegularLabel,
} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
-import axios from '~/lib/utils/axios_utils';
+import updateIssueLabelsMutation from '~/boards/queries/issue_set_labels.mutation.graphql';
+import { MutationOperationMode } from '~/graphql_shared/utils';
+import { IssuableType } from '~/issue_show/constants';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
+import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql';
+import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
describe('sidebar labels', () => {
- let axiosMock;
let wrapper;
const defaultProps = {
@@ -23,29 +25,52 @@ describe('sidebar labels', () => {
issuableType: 'issue',
labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true',
labelsManagePath: '/gitlab-org/gitlab-test/-/labels',
- labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json',
projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
projectPath: 'gitlab-org/gitlab-test',
};
+ const $apollo = {
+ mutate: jest.fn().mockResolvedValue(),
+ };
+
+ const userUpdatedLabels = [
+ {
+ ...mockRegularLabel,
+ set: false,
+ },
+ {
+ id: 40,
+ title: 'Security',
+ color: '#ddd',
+ text_color: '#fff',
+ set: true,
+ },
+ {
+ id: 55,
+ title: 'Tooling',
+ color: '#ddd',
+ text_color: '#fff',
+ set: false,
+ },
+ ];
+
const findLabelsSelect = () => wrapper.find(LabelsSelect);
- const mountComponent = () => {
+ const mountComponent = (props = {}) => {
wrapper = shallowMount(SidebarLabels, {
provide: {
...defaultProps,
+ ...props,
+ },
+ mocks: {
+ $apollo,
},
});
};
- beforeEach(() => {
- axiosMock = new AxiosMockAdapter(axios);
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
- axiosMock.restore();
});
describe('LabelsSelect props', () => {
@@ -72,64 +97,94 @@ describe('sidebar labels', () => {
});
});
- describe('when labels are updated', () => {
+ describe('when type is issue', () => {
beforeEach(() => {
- mountComponent();
+ mountComponent({ issuableType: IssuableType.Issue });
});
- it('makes an API call to update labels', async () => {
- const labels = [
- {
- ...mockRegularLabel,
- set: false,
- },
- {
- id: 40,
- title: 'Security',
- color: '#ddd',
- text_color: '#fff',
- set: true,
- },
- {
- id: 55,
- title: 'Tooling',
- color: '#ddd',
- text_color: '#fff',
- set: false,
- },
- ];
-
- findLabelsSelect().vm.$emit('updateSelectedLabels', labels);
-
- await axios.waitForAll();
-
- const expected = {
- [defaultProps.issuableType]: {
- label_ids: [27, 28, 29, 40],
- },
- };
-
- expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
+ describe('when labels are updated', () => {
+ it('invokes a mutation', () => {
+ findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
+
+ const expected = {
+ mutation: updateIssueLabelsMutation,
+ variables: {
+ input: {
+ addLabelIds: [40],
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ removeLabelIds: [26, 55],
+ },
+ },
+ };
+
+ expect($apollo.mutate).toHaveBeenCalledWith(expected);
+ });
+ });
+
+ describe('when label `x` is clicked', () => {
+ it('invokes a mutation', () => {
+ findLabelsSelect().vm.$emit('onLabelRemove', 27);
+
+ const expected = {
+ mutation: updateIssueLabelsMutation,
+ variables: {
+ input: {
+ iid: defaultProps.iid,
+ projectPath: defaultProps.projectPath,
+ removeLabelIds: [27],
+ },
+ },
+ };
+
+ expect($apollo.mutate).toHaveBeenCalledWith(expected);
+ });
});
});
- describe('when label `x` is clicked', () => {
+ describe('when type is merge_request', () => {
beforeEach(() => {
- mountComponent();
+ mountComponent({ issuableType: IssuableType.MergeRequest });
});
- it('makes an API call to update labels', async () => {
- findLabelsSelect().vm.$emit('onLabelRemove', 27);
-
- await axios.waitForAll();
-
- const expected = {
- [defaultProps.issuableType]: {
- label_ids: [26, 28, 29],
- },
- };
+ describe('when labels are updated', () => {
+ it('invokes a mutation', () => {
+ findLabelsSelect().vm.$emit('updateSelectedLabels', userUpdatedLabels);
+
+ const expected = {
+ mutation: updateMergeRequestLabelsMutation,
+ variables: {
+ input: {
+ iid: defaultProps.iid,
+ labelIds: [toLabelGid(27), toLabelGid(28), toLabelGid(29), toLabelGid(40)],
+ operationMode: MutationOperationMode.Replace,
+ projectPath: defaultProps.projectPath,
+ },
+ },
+ };
+
+ expect($apollo.mutate).toHaveBeenCalledWith(expected);
+ });
+ });
- expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected));
+ describe('when label `x` is clicked', () => {
+ it('invokes a mutation', () => {
+ findLabelsSelect().vm.$emit('onLabelRemove', 27);
+
+ const expected = {
+ mutation: updateMergeRequestLabelsMutation,
+ variables: {
+ input: {
+ iid: defaultProps.iid,
+ labelIds: [toLabelGid(27)],
+ operationMode: MutationOperationMode.Remove,
+ projectPath: defaultProps.projectPath,
+ },
+ },
+ };
+
+ expect($apollo.mutate).toHaveBeenCalledWith(expected);
+ });
});
});
});
diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js
index dddb9c2bba9..428441656b3 100644
--- a/spec/frontend/sidebar/subscriptions_spec.js
+++ b/spec/frontend/sidebar/subscriptions_spec.js
@@ -94,7 +94,7 @@ describe('Subscriptions', () => {
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(
+ expect(wrapper.find({ ref: 'tooltip' }).attributes('title')).toBe(
subscribeDisabledDescription,
);
});
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 93684ed48ee..cef5f8cc528 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
@@ -52,9 +52,13 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] =
<div
class="div-dropzone-hover"
>
- <i
- class="fa fa-paperclip div-dropzone-icon"
- />
+ <svg
+ class="div-dropzone-icon s24"
+ >
+ <use
+ xlink:href="undefined#paperclip"
+ />
+ </svg>
</div>
</div>
diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js
index c1fad8cebe6..3521733ee5e 100644
--- a/spec/frontend/snippets/components/edit_spec.js
+++ b/spec/frontend/snippets/components/edit_spec.js
@@ -1,7 +1,9 @@
-import { ApolloMutation } from 'vue-apollo';
+import VueApollo, { ApolloMutation } from 'vue-apollo';
import { GlLoadingIcon } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
import waitForPromises from 'helpers/wait_for_promises';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import GetSnippetQuery from 'shared_queries/snippet/snippet.query.graphql';
import { deprecatedCreateFlash as Flash } from '~/flash';
import * as urlUtils from '~/lib/utils/url_utility';
import SnippetEditApp from '~/snippets/components/edit.vue';
@@ -10,7 +12,11 @@ import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit
import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue';
import TitleField from '~/vue_shared/components/form/title.vue';
import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue';
-import { SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants';
+import {
+ SNIPPET_VISIBILITY_PRIVATE,
+ SNIPPET_VISIBILITY_INTERNAL,
+ SNIPPET_VISIBILITY_PUBLIC,
+} from '~/snippets/constants';
import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql';
import { testEntries } from '../test_utils';
@@ -47,8 +53,12 @@ const createTestSnippet = () => ({
describe('Snippet Edit app', () => {
let wrapper;
+ let fakeApollo;
const relativeUrlRoot = '/foo/';
const originalRelativeUrlRoot = gon.relative_url_root;
+ const GetSnippetQuerySpy = jest.fn().mockResolvedValue({
+ data: { snippets: { nodes: [createTestSnippet()] } },
+ });
const mutationTypes = {
RESOLVE: jest.fn().mockResolvedValue({
@@ -78,12 +88,10 @@ describe('Snippet Edit app', () => {
props = {},
loading = false,
mutationRes = mutationTypes.RESOLVE,
+ selectedLevel = SNIPPET_VISIBILITY_PRIVATE,
+ withApollo = false,
} = {}) {
- if (wrapper) {
- throw new Error('wrapper already exists');
- }
-
- wrapper = shallowMount(SnippetEditApp, {
+ let componentData = {
mocks: {
$apollo: {
queries: {
@@ -92,23 +100,35 @@ describe('Snippet Edit app', () => {
mutate: mutationRes,
},
},
+ };
+
+ if (withApollo) {
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[GetSnippetQuery, GetSnippetQuerySpy]];
+ fakeApollo = createMockApollo(requestHandlers);
+ componentData = {
+ localVue,
+ apolloProvider: fakeApollo,
+ };
+ }
+
+ wrapper = shallowMount(SnippetEditApp, {
+ ...componentData,
stubs: {
ApolloMutation,
FormFooterActions,
},
+ provide: {
+ selectedLevel,
+ },
propsData: {
snippetGid: 'gid://gitlab/PersonalSnippet/42',
markdownPreviewPath: 'http://preview.foo.bar',
markdownDocsPath: 'http://docs.foo.bar',
...props,
},
- data() {
- return {
- snippet: {
- visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
- },
- };
- },
});
}
@@ -152,16 +172,13 @@ describe('Snippet Edit app', () => {
if (nodes.length) {
wrapper.setData({
snippet: nodes[0],
+ newSnippet: false,
+ });
+ } else {
+ wrapper.setData({
+ newSnippet: true,
});
}
-
- wrapper.vm.onSnippetFetch({
- data: {
- snippets: {
- nodes,
- },
- },
- });
};
describe('rendering', () => {
@@ -228,6 +245,28 @@ describe('Snippet Edit app', () => {
});
describe('functionality', () => {
+ it('does not fetch snippet when create a new snippet', async () => {
+ createComponent({ props: { snippetGid: '' }, withApollo: true });
+
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(GetSnippetQuerySpy).not.toHaveBeenCalled();
+ });
+
+ describe('default visibility', () => {
+ it.each([SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC])(
+ 'marks %s visibility by default',
+ async visibility => {
+ createComponent({
+ props: { snippetGid: '' },
+ selectedLevel: visibility,
+ });
+ expect(wrapper.vm.snippet.visibilityLevel).toEqual(visibility);
+ },
+ );
+ });
+
describe('form submission handling', () => {
it.each`
snippetArg | projectPath | uploadedFiles | input | mutation
diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
index 3919e4d7993..3151090f388 100644
--- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
+++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js
@@ -1,7 +1,6 @@
import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue';
-import { defaultSnippetVisibilityLevels } from '~/snippets/utils/blob';
import {
SNIPPET_VISIBILITY,
SNIPPET_VISIBILITY_PRIVATE,
@@ -15,36 +14,25 @@ describe('Snippet Visibility Edit component', () => {
let wrapper;
const defaultHelpLink = '/foo/bar';
const defaultVisibilityLevel = 'private';
- const defaultVisibility = defaultSnippetVisibilityLevels([0, 10, 20]);
function createComponent({
propsData = {},
- visibilityLevels = defaultVisibility,
+ visibilityLevels = [0, 10, 20],
multipleLevelsRestricted = false,
deep = false,
} = {}) {
const method = deep ? mount : shallowMount;
- const $apollo = {
- queries: {
- defaultVisibility: {
- loading: false,
- },
- },
- };
wrapper = method.call(this, SnippetVisibilityEdit, {
- mock: { $apollo },
propsData: {
helpLink: defaultHelpLink,
isProjectSnippet: false,
value: defaultVisibilityLevel,
...propsData,
},
- data() {
- return {
- visibilityLevels,
- multipleLevelsRestricted,
- };
+ provide: {
+ visibilityLevels,
+ multipleLevelsRestricted,
},
});
}
@@ -108,7 +96,6 @@ describe('Snippet Visibility Edit component', () => {
it.each`
levels | resultOptions
- ${undefined} | ${[]}
${''} | ${[]}
${[]} | ${[]}
${[0]} | ${[RESULTING_OPTIONS[0]]}
@@ -117,7 +104,7 @@ describe('Snippet Visibility Edit component', () => {
${[0, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[20]]}
${[10, 20]} | ${[RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]}
`('renders correct visibility options for $levels', ({ levels, resultOptions }) => {
- createComponent({ visibilityLevels: defaultSnippetVisibilityLevels(levels), deep: true });
+ createComponent({ visibilityLevels: levels, deep: true });
expect(findRadiosData()).toEqual(resultOptions);
});
@@ -132,7 +119,7 @@ describe('Snippet Visibility Edit component', () => {
'renders correct information about restricted visibility levels for $levels',
({ levels, levelsRestricted, resultText }) => {
createComponent({
- visibilityLevels: defaultSnippetVisibilityLevels(levels),
+ visibilityLevels: levels,
multipleLevelsRestricted: levelsRestricted,
});
expect(findRestrictedInfo().text()).toBe(resultText);
diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js
index 7e90b53dd07..247aff57c1a 100644
--- a/spec/frontend/static_site_editor/components/edit_area_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_area_spec.js
@@ -15,6 +15,11 @@ import {
sourceContentHeaderObjYAML as headerSettings,
sourceContentBody as body,
returnUrl,
+ mounts,
+ project,
+ branch,
+ baseUrl,
+ imageRoot,
} from '../mock_data';
jest.mock('~/static_site_editor/services/formatter', () => jest.fn(str => `${str} format-pass`));
@@ -31,6 +36,11 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
title,
content,
returnUrl,
+ mounts,
+ project,
+ branch,
+ baseUrl,
+ imageRoot,
savingChanges,
...propsData,
},
diff --git a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js
index 191f91be076..b887570e947 100644
--- a/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_meta_controls_spec.js
@@ -1,15 +1,12 @@
import { shallowMount } from '@vue/test-utils';
-import { useLocalStorageSpy } from 'helpers/local_storage_helper';
-import { GlFormInput, GlFormTextarea } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem, GlFormInput, GlFormTextarea } from '@gitlab/ui';
import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue';
-import { mergeRequestMeta } from '../mock_data';
+import { mergeRequestMeta, mergeRequestTemplates } from '../mock_data';
describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
- useLocalStorageSpy();
-
let wrapper;
let mockSelect;
let mockGlFormInputTitleInstance;
@@ -22,6 +19,8 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
propsData: {
title,
description,
+ templates: mergeRequestTemplates,
+ currentTemplate: null,
...propsData,
},
});
@@ -34,6 +33,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
};
const findGlFormInputTitle = () => wrapper.find(GlFormInput);
+ const findGlDropdownDescriptionTemplate = () => wrapper.find(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem);
+ const findDropdownItemByIndex = index => findAllDropdownItems().at(index);
+
const findGlFormTextAreaDescription = () => wrapper.find(GlFormTextarea);
beforeEach(() => {
@@ -52,6 +55,10 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(findGlFormInputTitle().exists()).toBe(true);
});
+ it('renders the description template dropdown', () => {
+ expect(findGlDropdownDescriptionTemplate().exists()).toBe(true);
+ });
+
it('renders the description input', () => {
expect(findGlFormTextAreaDescription().exists()).toBe(true);
});
@@ -68,6 +75,11 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(mockGlFormInputTitleInstance.$el.select).toHaveBeenCalled();
});
+ it('renders a GlDropdownItem per template plus one (for the starting none option)', () => {
+ expect(findDropdownItemByIndex(0).text()).toBe('None');
+ expect(findAllDropdownItems().length).toBe(mergeRequestTemplates.length + 1);
+ });
+
describe('when inputs change', () => {
const storageKey = 'sse-merge-request-meta-local-storage-editable';
@@ -86,14 +98,18 @@ describe('~/static_site_editor/components/edit_meta_controls.vue', () => {
expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings);
});
+ });
- it('should remember the input changes', () => {
- findGlFormInputTitle().vm.$emit('input', newTitle);
- findGlFormTextAreaDescription().vm.$emit('input', newDescription);
-
- const newSettings = { title: newTitle, description: newDescription };
-
- expect(localStorage.setItem).toHaveBeenCalledWith(storageKey, JSON.stringify(newSettings));
+ describe('when templates change', () => {
+ it.each`
+ index | value
+ ${0} | ${null}
+ ${1} | ${mergeRequestTemplates[0]}
+ ${2} | ${mergeRequestTemplates[1]}
+ `('emits a change template event when $index is clicked', ({ index, value }) => {
+ findDropdownItemByIndex(index).vm.$emit('click');
+
+ expect(wrapper.emitted('changeTemplate')[0][0]).toBe(value);
});
});
});
diff --git a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js
index 7a5685033f3..c7d0abee05c 100644
--- a/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js
+++ b/spec/frontend/static_site_editor/components/edit_meta_modal_spec.js
@@ -1,48 +1,87 @@
import { shallowMount } from '@vue/test-utils';
-
import { GlModal } from '@gitlab/ui';
-
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import EditMetaModal from '~/static_site_editor/components/edit_meta_modal.vue';
import EditMetaControls from '~/static_site_editor/components/edit_meta_controls.vue';
-
-import { sourcePath, mergeRequestMeta } from '../mock_data';
+import { MR_META_LOCAL_STORAGE_KEY } from '~/static_site_editor/constants';
+import {
+ sourcePath,
+ mergeRequestMeta,
+ mergeRequestTemplates,
+ project as namespaceProject,
+} from '../mock_data';
describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
+ useLocalStorageSpy();
+
let wrapper;
- let resetCachedEditable;
- let mockEditMetaControlsInstance;
+ let mockAxios;
const { title, description } = mergeRequestMeta;
+ const [namespace, project] = namespaceProject.split('/');
- const buildWrapper = (propsData = {}) => {
+ const buildWrapper = (propsData = {}, data = {}) => {
wrapper = shallowMount(EditMetaModal, {
propsData: {
sourcePath,
+ namespace,
+ project,
...propsData,
},
+ data: () => data,
});
};
- const buildMocks = () => {
- resetCachedEditable = jest.fn();
- mockEditMetaControlsInstance = { resetCachedEditable };
- wrapper.vm.$refs.editMetaControls = mockEditMetaControlsInstance;
+ const buildMockAxios = () => {
+ mockAxios = new MockAdapter(axios);
+ const templatesMergeRequestsPath = `templates/merge_request`;
+ mockAxios
+ .onGet(`${namespace}/${project}/${templatesMergeRequestsPath}`)
+ .reply(200, mergeRequestTemplates);
+ };
+
+ const buildMockRefs = () => {
+ wrapper.vm.$refs.editMetaControls = { resetCachedEditable: jest.fn() };
};
const findGlModal = () => wrapper.find(GlModal);
const findEditMetaControls = () => wrapper.find(EditMetaControls);
+ const findLocalStorageSync = () => wrapper.find(LocalStorageSync);
beforeEach(() => {
+ localStorage.setItem(MR_META_LOCAL_STORAGE_KEY);
+
+ buildMockAxios();
buildWrapper();
- buildMocks();
+ buildMockRefs();
return wrapper.vm.$nextTick();
});
afterEach(() => {
+ mockAxios.restore();
+
wrapper.destroy();
wrapper = null;
});
+ it('initializes initial merge request meta with local storage data', async () => {
+ const localStorageMeta = {
+ title: 'stored title',
+ description: 'stored description',
+ templates: null,
+ currentTemplate: null,
+ };
+
+ findLocalStorageSync().vm.$emit('input', localStorageMeta);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findEditMetaControls().props()).toEqual(localStorageMeta);
+ });
+
it('renders the modal', () => {
expect(findGlModal().exists()).toBe(true);
});
@@ -63,18 +102,70 @@ describe('~/static_site_editor/components/edit_meta_modal.vue', () => {
expect(findEditMetaControls().props('description')).toBe(description);
});
- it('emits the primary event with mergeRequestMeta', () => {
- findGlModal().vm.$emit('primary', mergeRequestMeta);
- expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]);
+ it('forwards the templates prop', () => {
+ expect(findEditMetaControls().props('templates')).toBe(null);
+ });
+
+ it('forwards the currentTemplate prop', () => {
+ expect(findEditMetaControls().props('currentTemplate')).toBe(null);
+ });
+
+ describe('when save button is clicked', () => {
+ beforeEach(() => {
+ findGlModal().vm.$emit('primary', mergeRequestMeta);
+ });
+
+ it('removes merge request meta from local storage', () => {
+ expect(findLocalStorageSync().props().clear).toBe(true);
+ });
+
+ it('emits the primary event with mergeRequestMeta', () => {
+ expect(wrapper.emitted('primary')).toEqual([[mergeRequestMeta]]);
+ });
});
- it('calls resetCachedEditable on EditMetaControls when primary emits', () => {
- findGlModal().vm.$emit('primary', mergeRequestMeta);
- expect(mockEditMetaControlsInstance.resetCachedEditable).toHaveBeenCalled();
+ describe('when templates exist', () => {
+ const template1 = mergeRequestTemplates[0];
+
+ beforeEach(() => {
+ buildWrapper({}, { templates: mergeRequestTemplates, currentTemplate: null });
+ });
+
+ it('sets the currentTemplate on the changeTemplate event', async () => {
+ findEditMetaControls().vm.$emit('changeTemplate', template1);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findEditMetaControls().props().currentTemplate).toBe(template1);
+
+ findEditMetaControls().vm.$emit('changeTemplate', null);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findEditMetaControls().props().currentTemplate).toBe(null);
+ });
+
+ it('updates the description on the changeTemplate event', async () => {
+ findEditMetaControls().vm.$emit('changeTemplate', template1);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findEditMetaControls().props().description).toEqual(template1.content);
+ });
});
it('emits the hide event', () => {
findGlModal().vm.$emit('hide');
expect(wrapper.emitted('hide')).toEqual([[]]);
});
+
+ it('stores merge request meta changes in local storage when changes happen', async () => {
+ const newMeta = { title: 'new title', description: 'new description' };
+
+ findEditMetaControls().vm.$emit('updateSettings', newMeta);
+
+ await wrapper.vm.$nextTick();
+
+ expect(findLocalStorageSync().props('value')).toEqual(newMeta);
+ });
});
diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js
index 0b08e290227..8bc65c6ce31 100644
--- a/spec/frontend/static_site_editor/mock_data.js
+++ b/spec/frontend/static_site_editor/mock_data.js
@@ -27,6 +27,7 @@ export const sourceContentTitle = 'Handbook';
export const username = 'gitlabuser';
export const projectId = '123456';
+export const project = 'user1/project1';
export const returnUrl = 'https://www.gitlab.com';
export const sourcePath = 'foobar.md.html';
export const mergeRequestMeta = {
@@ -47,6 +48,10 @@ export const savedContentMeta = {
url: 'foobar/-/merge_requests/123',
},
};
+export const mergeRequestTemplates = [
+ { key: 'Template1', name: 'Template 1', content: 'This is template 1!' },
+ { key: 'Template2', name: 'Template 2', content: 'This is template 2!' },
+];
export const submitChangesError = 'Could not save changes';
export const commitBranchResponse = {
@@ -67,3 +72,20 @@ export const images = new Map([
['path/to/image1.png', 'image1-content'],
['path/to/image2.png', 'image2-content'],
]);
+
+export const mounts = [
+ {
+ source: 'default/source/',
+ target: '',
+ },
+ {
+ source: 'source/with/target',
+ target: 'target',
+ },
+];
+
+export const branch = 'master';
+
+export const baseUrl = '/user1/project1/-/sse/master%2Ftest.md';
+
+export const imageRoot = 'source/images/';
diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js
index 2c69e884005..d0b72ad0cf0 100644
--- a/spec/frontend/static_site_editor/pages/home_spec.js
+++ b/spec/frontend/static_site_editor/pages/home_spec.js
@@ -12,7 +12,7 @@ import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants';
import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants';
import {
- projectId as project,
+ project,
returnUrl,
sourceContentYAML as content,
sourceContentTitle as title,
@@ -23,6 +23,10 @@ import {
submitChangesError,
trackingCategory,
images,
+ mounts,
+ branch,
+ baseUrl,
+ imageRoot,
} from '../mock_data';
const localVue = createLocalVue();
@@ -41,6 +45,10 @@ describe('static_site_editor/pages/home', () => {
project,
username,
sourcePath,
+ mounts,
+ branch,
+ baseUrl,
+ imageUploadPath: imageRoot,
};
const hasSubmittedChangesMutationPayload = {
data: {
@@ -119,6 +127,7 @@ describe('static_site_editor/pages/home', () => {
it('provides source content, returnUrl, and isSavingChanges to the edit area', () => {
expect(findEditArea().props()).toMatchObject({
title,
+ mounts,
content,
returnUrl,
savingChanges: false,
diff --git a/spec/frontend/static_site_editor/services/front_matterify_spec.js b/spec/frontend/static_site_editor/services/front_matterify_spec.js
index dbaedc30849..866897f21ef 100644
--- a/spec/frontend/static_site_editor/services/front_matterify_spec.js
+++ b/spec/frontend/static_site_editor/services/front_matterify_spec.js
@@ -11,6 +11,7 @@ describe('static_site_editor/services/front_matterify', () => {
const frontMatterifiedContent = {
source: content,
matter: yamlFrontMatterObj,
+ hasMatter: true,
spacing,
content: body,
delimiter: '---',
@@ -19,6 +20,7 @@ describe('static_site_editor/services/front_matterify', () => {
const frontMatterifiedBody = {
source: body,
matter: null,
+ hasMatter: false,
spacing: null,
content: body,
delimiter: null,
@@ -33,6 +35,12 @@ describe('static_site_editor/services/front_matterify', () => {
`('returns $target from $frontMatterified', ({ frontMatterified, target }) => {
expect(frontMatterified).toEqual(target);
});
+
+ it('should throw when matter is invalid', () => {
+ const invalidContent = `---\nkey: val\nkeyNoVal\n---\n${body}`;
+
+ expect(() => frontMatterify(invalidContent)).toThrow();
+ });
});
describe('stringify', () => {
diff --git a/spec/frontend/static_site_editor/services/renderers/render_image_spec.js b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js
new file mode 100644
index 00000000000..e9e40835982
--- /dev/null
+++ b/spec/frontend/static_site_editor/services/renderers/render_image_spec.js
@@ -0,0 +1,96 @@
+import imageRenderer from '~/static_site_editor/services/renderers/render_image';
+import { mounts, project, branch, baseUrl } from '../../mock_data';
+
+describe('rich_content_editor/renderers/render_image', () => {
+ let renderer;
+ let imageRepository;
+
+ beforeEach(() => {
+ renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
+ imageRepository = { get: () => null };
+ });
+
+ describe('build', () => {
+ it('builds a renderer object containing `canRender` and `render` functions', () => {
+ expect(renderer).toHaveProperty('canRender', expect.any(Function));
+ expect(renderer).toHaveProperty('render', expect.any(Function));
+ });
+ });
+
+ describe('canRender', () => {
+ it.each`
+ input | result
+ ${{ type: 'image' }} | ${true}
+ ${{ type: 'text' }} | ${false}
+ ${{ type: 'htmlBlock' }} | ${false}
+ `('returns $result when input is $input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
+ });
+ });
+
+ describe('render', () => {
+ let skipChildren;
+ let context;
+ let node;
+
+ beforeEach(() => {
+ skipChildren = jest.fn();
+ context = { skipChildren };
+ node = {
+ firstChild: {
+ type: 'img',
+ literal: 'Some Image',
+ },
+ };
+ });
+
+ it.each`
+ destination | isAbsolute | src
+ ${'http://test.host/absolute/path/to/image.png'} | ${true} | ${'http://test.host/absolute/path/to/image.png'}
+ ${'/relative/path/to/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/default/source/relative/path/to/image.png'}
+ ${'/target/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/source/with/target/image.png'}
+ ${'relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/relative/to/current/image.png'}
+ ${'./relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/./relative/to/current/image.png'}
+ ${'../relative/to/current/image.png'} | ${false} | ${'http://test.host/user1/project1/-/raw/master/../relative/to/current/image.png'}
+ `('returns an image with the correct attributes', ({ destination, isAbsolute, src }) => {
+ node.destination = destination;
+
+ const result = renderer.render(node, context);
+
+ expect(result).toEqual({
+ type: 'openTag',
+ tagName: 'img',
+ selfClose: true,
+ attributes: {
+ 'data-original-src': !isAbsolute ? destination : '',
+ src,
+ alt: 'Some Image',
+ },
+ });
+
+ expect(skipChildren).toHaveBeenCalled();
+ });
+
+ it('renders an image if a cached image is found in the repository, use the base64 content as the source', () => {
+ const imageContent = 'some-content';
+ const originalSrc = 'path/to/image.png';
+
+ imageRepository.get = () => imageContent;
+ renderer = imageRenderer.build(mounts, project, branch, baseUrl, imageRepository);
+ node.destination = originalSrc;
+
+ const result = renderer.render(node, context);
+
+ expect(result).toEqual({
+ type: 'openTag',
+ tagName: 'img',
+ selfClose: true,
+ attributes: {
+ 'data-original-src': originalSrc,
+ src: `data:image;base64,${imageContent}`,
+ alt: 'Some Image',
+ },
+ });
+ });
+ });
+});
diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js
new file mode 100644
index 00000000000..c86160e18f3
--- /dev/null
+++ b/spec/frontend/terraform/components/empty_state_spec.js
@@ -0,0 +1,26 @@
+import { GlEmptyState, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import EmptyState from '~/terraform/components/empty_state.vue';
+
+describe('EmptyStateComponent', () => {
+ let wrapper;
+
+ const propsData = {
+ image: '/image/path',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlSprintf } });
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should render content', () => {
+ expect(wrapper.find(GlEmptyState).exists()).toBe(true);
+ expect(wrapper.text()).toContain('Get started with Terraform');
+ });
+});
diff --git a/spec/frontend/terraform/components/states_table_spec.js b/spec/frontend/terraform/components/states_table_spec.js
new file mode 100644
index 00000000000..7a8cb19971e
--- /dev/null
+++ b/spec/frontend/terraform/components/states_table_spec.js
@@ -0,0 +1,102 @@
+import { GlIcon, GlTooltip } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import { useFakeDate } from 'helpers/fake_date';
+import StatesTable from '~/terraform/components/states_table.vue';
+
+describe('StatesTable', () => {
+ let wrapper;
+ useFakeDate([2020, 10, 15]);
+
+ const propsData = {
+ states: [
+ {
+ name: 'state-1',
+ lockedAt: '2020-10-13T00:00:00Z',
+ lockedByUser: {
+ name: 'user-1',
+ },
+ updatedAt: '2020-10-13T00:00:00Z',
+ latestVersion: null,
+ },
+ {
+ name: 'state-2',
+ lockedAt: null,
+ lockedByUser: null,
+ updatedAt: '2020-10-10T00:00:00Z',
+ latestVersion: null,
+ },
+ {
+ name: 'state-3',
+ lockedAt: '2020-10-10T00:00:00Z',
+ lockedByUser: {
+ name: 'user-2',
+ },
+ updatedAt: '2020-10-10T00:00:00Z',
+ latestVersion: {
+ updatedAt: '2020-10-11T00:00:00Z',
+ createdByUser: {
+ name: 'user-3',
+ },
+ },
+ },
+ {
+ name: 'state-4',
+ lockedAt: '2020-10-10T00:00:00Z',
+ lockedByUser: null,
+ updatedAt: '2020-10-10T00:00:00Z',
+ latestVersion: {
+ updatedAt: '2020-10-09T00:00:00Z',
+ createdByUser: null,
+ },
+ },
+ ],
+ };
+
+ beforeEach(() => {
+ wrapper = mount(StatesTable, { propsData });
+ return wrapper.vm.$nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it.each`
+ name | toolTipText | locked | lineNumber
+ ${'state-1'} | ${'Locked by user-1 2 days ago'} | ${true} | ${0}
+ ${'state-2'} | ${null} | ${false} | ${1}
+ ${'state-3'} | ${'Locked by user-2 5 days ago'} | ${true} | ${2}
+ ${'state-4'} | ${'Locked by Unknown User 5 days ago'} | ${true} | ${3}
+ `(
+ 'displays the name and locked information "$name" for line "$lineNumber"',
+ ({ name, toolTipText, locked, lineNumber }) => {
+ const states = wrapper.findAll('[data-testid="terraform-states-table-name"]');
+
+ const state = states.at(lineNumber);
+ const toolTip = state.find(GlTooltip);
+
+ expect(state.text()).toContain(name);
+ expect(state.find(GlIcon).exists()).toBe(locked);
+ expect(toolTip.exists()).toBe(locked);
+
+ if (locked) {
+ expect(toolTip.text()).toMatchInterpolatedText(toolTipText);
+ }
+ },
+ );
+
+ it.each`
+ updateTime | lineNumber
+ ${'updated 2 days ago'} | ${0}
+ ${'updated 5 days ago'} | ${1}
+ ${'user-3 updated 4 days ago'} | ${2}
+ ${'updated 6 days ago'} | ${3}
+ `('displays the time "$updateTime" for line "$lineNumber"', ({ updateTime, lineNumber }) => {
+ const states = wrapper.findAll('[data-testid="terraform-states-table-updated"]');
+
+ const state = states.at(lineNumber);
+
+ expect(state.text()).toMatchInterpolatedText(updateTime);
+ });
+});
diff --git a/spec/frontend/terraform/components/terraform_list_spec.js b/spec/frontend/terraform/components/terraform_list_spec.js
new file mode 100644
index 00000000000..b31afecc816
--- /dev/null
+++ b/spec/frontend/terraform/components/terraform_list_spec.js
@@ -0,0 +1,172 @@
+import { GlAlert, GlBadge, GlKeysetPagination, GlLoadingIcon, GlTab } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import VueApollo from 'vue-apollo';
+import EmptyState from '~/terraform/components/empty_state.vue';
+import StatesTable from '~/terraform/components/states_table.vue';
+import TerraformList from '~/terraform/components/terraform_list.vue';
+import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql';
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('TerraformList', () => {
+ let wrapper;
+
+ const propsData = {
+ emptyStateImage: '/path/to/image',
+ projectPath: 'path/to/project',
+ };
+
+ const createWrapper = ({ terraformStates, queryResponse = null }) => {
+ const apolloQueryResponse = {
+ data: {
+ project: {
+ terraformStates,
+ },
+ },
+ };
+
+ const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse);
+ const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]);
+
+ wrapper = shallowMount(TerraformList, {
+ localVue,
+ apolloProvider,
+ propsData,
+ });
+ };
+
+ const findBadge = () => wrapper.find(GlBadge);
+ const findEmptyState = () => wrapper.find(EmptyState);
+ const findPaginationButtons = () => wrapper.find(GlKeysetPagination);
+ const findStatesTable = () => wrapper.find(StatesTable);
+ const findTab = () => wrapper.find(GlTab);
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when the terraform query has succeeded', () => {
+ describe('when there is a list of terraform states', () => {
+ const states = [
+ {
+ id: 'gid://gitlab/Terraform::State/1',
+ name: 'state-1',
+ lockedAt: null,
+ updatedAt: null,
+ lockedByUser: null,
+ latestVersion: null,
+ },
+ {
+ id: 'gid://gitlab/Terraform::State/2',
+ name: 'state-2',
+ lockedAt: null,
+ updatedAt: null,
+ lockedByUser: null,
+ latestVersion: null,
+ },
+ ];
+
+ beforeEach(() => {
+ createWrapper({
+ terraformStates: {
+ nodes: states,
+ count: states.length,
+ pageInfo: {
+ hasNextPage: true,
+ hasPreviousPage: false,
+ startCursor: 'prev',
+ endCursor: 'next',
+ },
+ },
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a states tab and count', () => {
+ expect(findTab().text()).toContain('States');
+ expect(findBadge().text()).toBe('2');
+ });
+
+ it('renders the states table and pagination buttons', () => {
+ expect(findStatesTable().exists()).toBe(true);
+ expect(findPaginationButtons().exists()).toBe(true);
+ });
+
+ describe('when list has no additional pages', () => {
+ beforeEach(() => {
+ createWrapper({
+ terraformStates: {
+ nodes: states,
+ count: states.length,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ },
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('renders the states table without pagination buttons', () => {
+ expect(findStatesTable().exists()).toBe(true);
+ expect(findPaginationButtons().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('when the list of terraform states is empty', () => {
+ beforeEach(() => {
+ createWrapper({
+ terraformStates: {
+ nodes: [],
+ count: 0,
+ pageInfo: null,
+ },
+ });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays a states tab with no count', () => {
+ expect(findTab().text()).toContain('States');
+ expect(findBadge().exists()).toBe(false);
+ });
+
+ it('renders the empty state', () => {
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when the terraform query has errored', () => {
+ beforeEach(() => {
+ createWrapper({ terraformStates: null, queryResponse: jest.fn().mockRejectedValue() });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('displays an alert message', () => {
+ expect(wrapper.find(GlAlert).exists()).toBe(true);
+ });
+ });
+
+ describe('when the terraform query is loading', () => {
+ beforeEach(() => {
+ createWrapper({
+ terraformStates: null,
+ queryResponse: jest.fn().mockReturnValue(new Promise(() => {})),
+ });
+ });
+
+ it('displays a loading icon', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js
index 0edc5248629..50848ca2978 100644
--- a/spec/frontend/tooltips/components/tooltips_spec.js
+++ b/spec/frontend/tooltips/components/tooltips_spec.js
@@ -80,6 +80,14 @@ describe('tooltips/components/tooltips.vue', () => {
expect(wrapper.find(GlTooltip).html()).toContain(target.getAttribute('title'));
});
+ it('sets the configuration values passed in the config object', async () => {
+ const config = { show: true };
+ target = createTooltipTarget();
+ wrapper.vm.addTooltips([target], config);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find(GlTooltip).props()).toMatchObject(config);
+ });
+
it.each`
attribute | value | prop
${'data-placement'} | ${'bottom'} | ${'placement'}
diff --git a/spec/frontend/tooltips/index_spec.js b/spec/frontend/tooltips/index_spec.js
index cc72adee57d..511003fdb8f 100644
--- a/spec/frontend/tooltips/index_spec.js
+++ b/spec/frontend/tooltips/index_spec.js
@@ -1,5 +1,15 @@
import jQuery from 'jquery';
-import { initTooltips, dispose, destroy, hide, show, enable, disable, fixTitle } from '~/tooltips';
+import {
+ add,
+ initTooltips,
+ dispose,
+ destroy,
+ hide,
+ show,
+ enable,
+ disable,
+ fixTitle,
+} from '~/tooltips';
describe('tooltips/index.js', () => {
let tooltipsApp;
@@ -67,6 +77,20 @@ describe('tooltips/index.js', () => {
});
});
+ describe('add', () => {
+ it('adds a GlTooltip for the specified elements', async () => {
+ const target = createTooltipTarget();
+
+ buildTooltipsApp();
+ add([target], { title: 'custom title' });
+
+ await tooltipsApp.$nextTick();
+
+ expect(document.querySelector('.gl-tooltip')).not.toBe(null);
+ expect(document.querySelector('.gl-tooltip').innerHTML).toContain('custom title');
+ });
+ });
+
describe('dispose', () => {
it('removes tooltips that target the elements specified', async () => {
const target = createTooltipTarget();
@@ -136,12 +160,13 @@ describe('tooltips/index.js', () => {
${disable} | ${'disable'} | ${'disable'}
${hide} | ${'hide'} | ${'hide'}
${show} | ${'show'} | ${'show'}
+ ${add} | ${'init'} | ${{ title: 'the title' }}
`('delegates $methodName to bootstrap tooltip API', ({ method, bootstrapParams }) => {
const elements = jQuery(createTooltipTarget());
jest.spyOn(jQuery.fn, 'tooltip');
- method(elements);
+ method(elements, bootstrapParams);
expect(elements.tooltip).toHaveBeenCalledWith(bootstrapParams);
});
diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js
index 8c2bef60e74..d4b97532cdd 100644
--- a/spec/frontend/tracking_spec.js
+++ b/spec/frontend/tracking_spec.js
@@ -31,6 +31,7 @@ describe('Tracking', () => {
contexts: { webPage: true, performanceTiming: true },
formTracking: false,
linkClickTracking: false,
+ pageUnloadTimer: 10,
});
});
});
diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js
new file mode 100644
index 00000000000..8f6fe3cd37a
--- /dev/null
+++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js
@@ -0,0 +1,31 @@
+import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions';
+import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue';
+
+describe('MR widget extension registering', () => {
+ it('registers a extension', () => {
+ registerExtension({
+ name: 'Test',
+ props: ['helloWorld'],
+ computed: {
+ test() {},
+ },
+ methods: {
+ test() {},
+ },
+ });
+
+ expect(extensions[0]).toEqual(
+ expect.objectContaining({
+ extends: ExtensionBase,
+ name: 'Test',
+ props: ['helloWorld'],
+ computed: {
+ test: expect.any(Function),
+ },
+ methods: {
+ test: expect.any(Function),
+ },
+ }),
+ );
+ });
+});
diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
index 015f8bbac51..266c906ba60 100644
--- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
+++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js
@@ -1,13 +1,7 @@
import Vue from 'vue';
-import Mousetrap from 'mousetrap';
import mountComponent from 'helpers/vue_mount_component_helper';
import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
-jest.mock('mousetrap', () => ({
- bind: jest.fn(),
- unbind: jest.fn(),
-}));
-
describe('MRWidgetHeader', () => {
let vm;
let Component;
@@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => {
it('renders target branch', () => {
expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
});
-
- describe('keyboard shortcuts', () => {
- it('binds a keyboard shortcut handler to the "b" key', () => {
- expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function));
- });
-
- it('triggers a click on the "copy to clipboard" button when the handler is executed', () => {
- const testClickHandler = jest.fn();
- vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler);
-
- // Get a reference to the function that was assigned to the "b" shortcut key.
- const shortcutHandler = Mousetrap.bind.mock.calls[0][1];
-
- expect(testClickHandler).not.toHaveBeenCalled();
-
- // Simulate Mousetrap calling the function.
- shortcutHandler();
-
- expect(testClickHandler).toHaveBeenCalledTimes(1);
- });
-
- it('unbinds the keyboard shortcut when the component is destroyed', () => {
- expect(Mousetrap.unbind).not.toHaveBeenCalled();
-
- vm.$destroy();
-
- expect(Mousetrap.unbind).toHaveBeenCalledWith('b');
- });
- });
});
describe('with an open merge request', () => {
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
index 5c7e6a87c16..56832f82b05 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdownItem } from '@gitlab/ui';
import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue';
const commits = [
@@ -39,7 +39,7 @@ describe('Commits message dropdown component', () => {
wrapper.destroy();
});
- const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem);
+ const findDropdownElements = () => wrapper.findAll(GlDropdownItem);
const findFirstDropdownElement = () => findDropdownElements().at(0);
it('should have 3 elements in dropdown list', () => {
diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
index 6ccf1e1f56b..907906ebe98 100644
--- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
+++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js
@@ -84,7 +84,7 @@ describe('Wip', () => {
it('should have correct elements', () => {
expect(el.classList.contains('mr-widget-body')).toBeTruthy();
- expect(el.innerText).toContain('This merge request is still a work in progress.');
+ expect(el.innerText).toContain('This merge request is still a draft.');
expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy();
expect(el.querySelector('button').innerText).toContain('Merge');
expect(el.querySelector('.js-remove-wip').innerText.replace(/\s\s+/g, ' ')).toContain(
diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
index 25c967996e3..d6f85dcfcc7 100644
--- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
+++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js
@@ -1,7 +1,6 @@
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import mountComponent from 'helpers/vue_mount_component_helper';
-import { withGonExperiment } from 'helpers/experimentation_helper';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import mrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
@@ -850,7 +849,7 @@ describe('mrWidgetOptions', () => {
});
});
- describe('suggestPipeline Experiment', () => {
+ describe('suggestPipeline feature flag', () => {
beforeEach(() => {
mock.onAny().reply(200);
@@ -859,10 +858,10 @@ describe('mrWidgetOptions', () => {
jest.spyOn(console, 'warn').mockImplementation();
});
- describe('given experiment is enabled', () => {
- withGonExperiment('suggestPipeline');
-
+ describe('given feature flag is enabled', () => {
beforeEach(() => {
+ gon.features = { suggestPipeline: true };
+
createComponent();
vm.mr.hasCI = false;
@@ -893,10 +892,10 @@ describe('mrWidgetOptions', () => {
});
});
- describe('given suggestPipeline experiment is not enabled', () => {
- withGonExperiment('suggestPipeline', false);
-
+ describe('given feature flag is not enabled', () => {
beforeEach(() => {
+ gon.features = { suggestPipeline: false };
+
createComponent();
vm.mr.hasCI = false;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
index 82503e5a025..04ae2a0f34d 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
@@ -6,10 +6,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
>
<button
class="btn award-control"
- data-boundary="viewport"
- data-original-title="Ada, Leonardo, and Marie"
data-testid="award-button"
- title=""
+ title="Ada, Leonardo, and Marie"
type="button"
>
<span
@@ -32,10 +30,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control active"
- data-boundary="viewport"
- data-original-title="You, Ada, and Marie"
data-testid="award-button"
- title=""
+ title="You, Ada, and Marie"
type="button"
>
<span
@@ -58,10 +54,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control"
- data-boundary="viewport"
- data-original-title="Ada and Jane"
data-testid="award-button"
- title=""
+ title="Ada and Jane"
type="button"
>
<span
@@ -84,10 +78,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control active"
- data-boundary="viewport"
- data-original-title="You, Ada, Jane, and Leonardo"
data-testid="award-button"
- title=""
+ title="You, Ada, Jane, and Leonardo"
type="button"
>
<span
@@ -110,10 +102,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control active"
- data-boundary="viewport"
- data-original-title="You"
data-testid="award-button"
- title=""
+ title="You"
type="button"
>
<span
@@ -136,10 +126,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control"
- data-boundary="viewport"
- data-original-title="Marie"
data-testid="award-button"
- title=""
+ title="Marie"
type="button"
>
<span
@@ -162,10 +150,8 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
</button>
<button
class="btn award-control active"
- data-boundary="viewport"
- data-original-title="You"
data-testid="award-button"
- title=""
+ title="You"
type="button"
>
<span
@@ -193,9 +179,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
<button
aria-label="Add reaction"
class="award-control btn js-add-award js-test-add-button-class"
- data-boundary="viewport"
- data-original-title="Add reaction"
- title=""
+ title="Add reaction"
type="button"
>
<span
diff --git a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap
index 5ab159a5a84..ca9d4488870 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/file_row_header_spec.js.snap
@@ -5,11 +5,11 @@ exports[`File row header component adds multiple ellipsises after 40 characters
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets/javascripts/merge_requests/widget/diffs/notes"
>
- <span
+ <gl-truncate-stub
class="bold"
- >
- app/assets/javascripts/…/…/diffs/notes
- </span>
+ position="middle"
+ text="app/assets/javascripts/merge_requests/widget/diffs/notes"
+ />
</div>
`;
@@ -18,11 +18,11 @@ exports[`File row header component renders file path 1`] = `
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets"
>
- <span
+ <gl-truncate-stub
class="bold"
- >
- app/assets
- </span>
+ position="middle"
+ text="app/assets"
+ />
</div>
`;
@@ -31,10 +31,10 @@ exports[`File row header component trucates path after 40 characters 1`] = `
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
title="app/assets/javascripts/merge_requests"
>
- <span
+ <gl-truncate-stub
class="bold"
- >
- app/assets/javascripts/merge_requests
- </span>
+ position="middle"
+ text="app/assets/javascripts/merge_requests"
+ />
</div>
`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap
new file mode 100644
index 00000000000..df0fcf5da1c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/__snapshots__/integration_help_text_spec.js.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`IntegrationHelpText component should not render the link when start and end is not provided 1`] = `
+<span>
+ Click nowhere!
+</span>
+`;
+
+exports[`IntegrationHelpText component should render the help text 1`] = `
+<span>
+ Click
+ <gl-link-stub
+ href="http://bar.com"
+ target="_blank"
+ >
+
+ Bar
+
+ <gl-icon-stub
+ class="gl-vertical-align-middle"
+ name="external-link"
+ size="12"
+ />
+ </gl-link-stub>
+ !
+</span>
+`;
diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
index dff307e92c2..ef7815f9e9e 100644
--- a/spec/frontend/vue_shared/components/alert_details_table_spec.js
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -23,14 +23,10 @@ const environmentPath = '/fake/path';
describe('AlertDetails', () => {
let environmentData = { name: environmentName, path: environmentPath };
- let glFeatures = { exposeEnvironmentPathInAlertDetails: false };
let wrapper;
function mountComponent(propsData = {}) {
wrapper = mount(AlertDetailsTable, {
- provide: {
- glFeatures,
- },
propsData: {
alert: {
...mockAlert,
@@ -97,34 +93,19 @@ describe('AlertDetails', () => {
expect(findTableField(fields, 'Severity').exists()).toBe(true);
expect(findTableField(fields, 'Status').exists()).toBe(true);
expect(findTableField(fields, 'Hosts').exists()).toBe(true);
- expect(findTableField(fields, 'Environment').exists()).toBe(false);
+ expect(findTableField(fields, 'Environment').exists()).toBe(true);
});
- it('should not show disallowed and flaggedAllowed alert fields', () => {
+ it('should not show disallowed alert fields', () => {
const fields = findTableKeys();
expect(findTableField(fields, 'Typename').exists()).toBe(false);
expect(findTableField(fields, 'Todos').exists()).toBe(false);
expect(findTableField(fields, 'Notes').exists()).toBe(false);
expect(findTableField(fields, 'Assignees').exists()).toBe(false);
- expect(findTableField(fields, 'Environment').exists()).toBe(false);
- });
- });
-
- describe('when exposeEnvironmentPathInAlertDetails is enabled', () => {
- beforeEach(() => {
- glFeatures = { exposeEnvironmentPathInAlertDetails: true };
- mountComponent();
- });
-
- it('should show flaggedAllowed alert fields', () => {
- const fields = findTableKeys();
-
- expect(findTableField(fields, 'Environment').exists()).toBe(true);
});
it('should display only the name for the environment', () => {
- expect(findTableFieldValueByKey('Iid').text()).toBe('1527542');
expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName);
});
diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js
index 0abb72ace2e..63fc8a5749d 100644
--- a/spec/frontend/vue_shared/components/awards_list_spec.js
+++ b/spec/frontend/vue_shared/components/awards_list_spec.js
@@ -62,7 +62,7 @@ describe('vue_shared/components/awards_list', () => {
findAwardButtons().wrappers.map(x => {
return {
classes: x.classes(),
- title: x.attributes('data-original-title'),
+ title: x.attributes('title'),
html: x.find('[data-testid="award-html"]').element.innerHTML,
count: Number(x.find('.js-counter').text()),
};
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 4909d2d4226..023895099b1 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
@@ -59,7 +59,7 @@ exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
class="code highlight"
>
<code
- id="blob-code-content"
+ data-blob-hash="foo-bar"
>
<span
id="LC1"
diff --git a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
index 79195aa1350..8434fdaccde 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/simple_viewer_spec.js
@@ -5,9 +5,13 @@ import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/const
describe('Blob Simple Viewer component', () => {
let wrapper;
const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`;
+ const blobHash = 'foo-bar';
function createComponent(content = contentMock) {
wrapper = shallowMount(SimpleViewer, {
+ provide: {
+ blobHash,
+ },
propsData: {
content,
type: 'text',
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index 8456ca9d125..96ccf56cbc6 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -62,7 +62,7 @@ describe('vue_shared/components/confirm_modal', () => {
wrapper.vm.modalAttributes = MOCK_MODAL_DATA.modalAttributes;
});
- it('renders GlModal wtih data', () => {
+ it('renders GlModal with data', () => {
expect(findModal().exists()).toBeTruthy();
expect(findModal().attributes()).toEqual(
expect.objectContaining({
@@ -72,6 +72,24 @@ describe('vue_shared/components/confirm_modal', () => {
);
});
});
+
+ describe.each`
+ desc | attrs | expectation
+ ${'when message is simple text'} | ${{}} | ${`<div>${MOCK_MODAL_DATA.modalAttributes.message}</div>`}
+ ${'when message has html'} | ${{ messageHtml: '<p>Header</p><ul onhover="alert(1)"><li>First</li></ul>' }} | ${'<p>Header</p><ul><li>First</li></ul>'}
+ `('$desc', ({ attrs, expectation }) => {
+ beforeEach(() => {
+ createComponent();
+ wrapper.vm.modalAttributes = {
+ ...MOCK_MODAL_DATA.modalAttributes,
+ ...attrs,
+ };
+ });
+
+ it('renders message', () => {
+ expect(findForm().element.innerHTML).toContain(expectation);
+ });
+ });
});
describe('methods', () => {
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
index 892a96b76fd..08e5d828b8f 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_button_spec.js
@@ -60,10 +60,9 @@ describe('DropdownButtonComponent', () => {
});
it('renders dropdown button icon', () => {
- const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa');
+ const dropdownIconEl = vm.$el.querySelector('[data-testid="chevron-down-icon"]');
expect(dropdownIconEl).not.toBeNull();
- expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
});
it('renders slot, if default slot exists', () => {
diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js
index d28c35d26bf..bd6a18bf704 100644
--- a/spec/frontend/vue_shared/components/file_row_spec.js
+++ b/spec/frontend/vue_shared/components/file_row_spec.js
@@ -3,6 +3,7 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
+import FileIcon from '~/vue_shared/components/file_icon.vue';
import { escapeFileUrl } from '~/lib/utils/url_utility';
describe('File row component', () => {
@@ -151,4 +152,18 @@ describe('File row component', () => {
expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold');
});
+
+ it('renders submodule icon', () => {
+ const submodule = true;
+
+ createComponent({
+ file: {
+ ...file(),
+ submodule,
+ },
+ level: 0,
+ });
+
+ expect(wrapper.find(FileIcon).props('submodule')).toBe(submodule);
+ });
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
index c79880d4766..64bfff3dfa1 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js
@@ -1,5 +1,12 @@
import { shallowMount, mount } from '@vue/test-utils';
-import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import {
+ GlFilteredSearch,
+ GlButtonGroup,
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlFormCheckbox,
+} from '@gitlab/ui';
import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
@@ -30,6 +37,8 @@ const createComponent = ({
recentSearchesStorageKey = 'requirements',
tokens = mockAvailableTokens,
sortOptions,
+ showCheckbox = false,
+ checkboxChecked = false,
searchInputPlaceholder = 'Filter requirements',
} = {}) => {
const mountMethod = shallow ? shallowMount : mount;
@@ -40,6 +49,8 @@ const createComponent = ({
recentSearchesStorageKey,
tokens,
sortOptions,
+ showCheckbox,
+ checkboxChecked,
searchInputPlaceholder,
},
});
@@ -364,6 +375,26 @@ describe('FilteredSearchBarRoot', () => {
expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems);
});
+ it('renders checkbox when `showCheckbox` prop is true', async () => {
+ let wrapperWithCheckbox = createComponent({
+ showCheckbox: true,
+ });
+
+ expect(wrapperWithCheckbox.find(GlFormCheckbox).exists()).toBe(true);
+ expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).not.toBeDefined();
+
+ wrapperWithCheckbox.destroy();
+
+ wrapperWithCheckbox = createComponent({
+ showCheckbox: true,
+ checkboxChecked: true,
+ });
+
+ expect(wrapperWithCheckbox.find(GlFormCheckbox).attributes('checked')).toBe('true');
+
+ wrapperWithCheckbox.destroy();
+ });
+
it('renders search history items dropdown with formatting done using token symbols', async () => {
const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false });
wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index 72840ce381f..3fd1d8b7f42 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -45,6 +45,7 @@ function createComponent(options = {}) {
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index 12b7fd58670..5b7f7d242e9 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -45,6 +45,7 @@ function createComponent(options = {}) {
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index 3feb05bab35..74172db81c2 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -50,6 +50,7 @@ function createComponent(options = {}) {
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
},
stubs,
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
index 0ec814e3f15..67f9a9c70cc 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js
@@ -48,6 +48,7 @@ function createComponent(options = {}) {
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
},
stubs,
});
@@ -120,7 +121,9 @@ describe('MilestoneToken', () => {
wrapper.vm.fetchMilestoneBySearchTerm('foo');
return waitForPromises().then(() => {
- expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.');
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching milestones.',
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/integration_help_text_spec.js b/spec/frontend/vue_shared/components/integration_help_text_spec.js
new file mode 100644
index 00000000000..4269d36d0e2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/integration_help_text_spec.js
@@ -0,0 +1,57 @@
+import { shallowMount } from '@vue/test-utils';
+
+import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui';
+import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';
+
+describe('IntegrationHelpText component', () => {
+ let wrapper;
+ const defaultProps = {
+ message: 'Click %{linkStart}Bar%{linkEnd}!',
+ messageUrl: 'http://bar.com',
+ };
+
+ function createComponent(props = {}) {
+ return shallowMount(IntegrationHelpText, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ GlSprintf,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should use the gl components', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.find(GlSprintf).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(true);
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('should render the help text', () => {
+ wrapper = createComponent();
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('should not use the gl-link and gl-icon components', () => {
+ wrapper = createComponent({ message: 'Click nowhere!' });
+
+ expect(wrapper.find(GlSprintf).exists()).toBe(true);
+ expect(wrapper.find(GlIcon).exists()).toBe(false);
+ expect(wrapper.find(GlLink).exists()).toBe(false);
+ });
+
+ it('should not render the link when start and end is not provided', () => {
+ wrapper = createComponent({ message: 'Click nowhere!' });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index efa9b5796fb..464fe3411dd 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -239,4 +239,30 @@ describe('Local Storage Sync', () => {
});
});
});
+
+ it('clears localStorage when clear property is true', async () => {
+ const storageKey = 'key';
+ const value = 'initial';
+
+ createComponent({
+ props: {
+ storageKey,
+ },
+ });
+ wrapper.setProps({
+ value,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(localStorage.getItem(storageKey)).toBe(value);
+
+ wrapper.setProps({
+ clear: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(localStorage.getItem(storageKey)).toBe(null);
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index b19e74b5b11..c0a000690f8 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -29,6 +29,10 @@ describe('Suggestion Diff component', () => {
});
};
+ beforeEach(() => {
+ window.gon.current_user_id = 1;
+ });
+
afterEach(() => {
wrapper.destroy();
});
@@ -71,6 +75,14 @@ describe('Suggestion Diff component', () => {
expect(addToBatchBtn.html().includes('Add suggestion to batch')).toBe(true);
});
+ it('does not render apply suggestion button with anonymous user', () => {
+ window.gon.current_user_id = null;
+
+ createComponent();
+
+ expect(findApplyButton().exists()).toBe(false);
+ });
+
describe('when apply suggestion is clicked', () => {
beforeEach(() => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js
index d7bb8c0d142..5674929716d 100644
--- a/spec/frontend/vue_shared/components/members/mock_data.js
+++ b/spec/frontend/vue_shared/components/members/mock_data.js
@@ -3,6 +3,7 @@ export const member = {
canUpdate: false,
canRemove: false,
canOverride: false,
+ isOverridden: false,
accessLevel: { integerValue: 50, stringValue: 'Owner' },
source: {
id: 178,
diff --git a/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js
new file mode 100644
index 00000000000..a1afdbc2b49
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/expiration_datepicker_spec.js
@@ -0,0 +1,166 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { nextTick } from 'vue';
+import { GlDatepicker } from '@gitlab/ui';
+import { useFakeDate } from 'helpers/fake_date';
+import waitForPromises from 'helpers/wait_for_promises';
+import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue';
+import { member } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ExpirationDatepicker', () => {
+ // March 15th, 2020 3:00
+ useFakeDate(2020, 2, 15, 3);
+
+ let wrapper;
+ let actions;
+ let resolveUpdateMemberExpiration;
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const createStore = () => {
+ actions = {
+ updateMemberExpiration: jest.fn(
+ () =>
+ new Promise(resolve => {
+ resolveUpdateMemberExpiration = resolve;
+ }),
+ ),
+ };
+
+ return new Vuex.Store({ actions });
+ };
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(ExpirationDatepicker, {
+ propsData: {
+ member,
+ permissions: { canUpdate: true },
+ ...propsData,
+ },
+ localVue,
+ store: createStore(),
+ mocks: {
+ $toast,
+ },
+ });
+ };
+
+ const findInput = () => wrapper.find('input');
+ const findDatepicker = () => wrapper.find(GlDatepicker);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('datepicker input', () => {
+ it('sets `member.expiresAt` as initial date', async () => {
+ createComponent({ member: { ...member, expiresAt: '2020-03-17T00:00:00Z' } });
+
+ await nextTick();
+
+ expect(findInput().element.value).toBe('2020-03-17');
+ });
+ });
+
+ describe('props', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('sets `minDate` prop as tomorrow', () => {
+ expect(
+ findDatepicker()
+ .props('minDate')
+ .toISOString(),
+ ).toBe(new Date('2020-3-16').toISOString());
+ });
+
+ it('sets `target` prop as `null` so datepicker opens on focus', () => {
+ expect(findDatepicker().props('target')).toBe(null);
+ });
+
+ it("sets `container` prop as `null` so table styles don't affect the datepicker styles", () => {
+ expect(findDatepicker().props('container')).toBe(null);
+ });
+
+ it('shows clear button', () => {
+ expect(findDatepicker().props('showClearButton')).toBe(true);
+ });
+ });
+
+ describe('when datepicker is changed', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findDatepicker().vm.$emit('input', new Date('2020-03-17'));
+ });
+
+ it('calls `updateMemberExpiration` Vuex action', () => {
+ expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), {
+ memberId: member.id,
+ expiresAt: new Date('2020-03-17'),
+ });
+ });
+
+ it('displays toast when successful', async () => {
+ resolveUpdateMemberExpiration();
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Expiration date updated successfully.');
+ });
+
+ it('disables dropdown while waiting for `updateMemberExpiration` to resolve', async () => {
+ expect(findDatepicker().props('disabled')).toBe(true);
+
+ resolveUpdateMemberExpiration();
+ await waitForPromises();
+
+ expect(findDatepicker().props('disabled')).toBe(false);
+ });
+ });
+
+ describe('when datepicker is cleared', () => {
+ beforeEach(async () => {
+ createComponent();
+
+ findInput().setValue('2020-03-17');
+ await nextTick();
+ wrapper.find('[data-testid="clear-button"]').trigger('click');
+ });
+
+ it('calls `updateMemberExpiration` Vuex action', () => {
+ expect(actions.updateMemberExpiration).toHaveBeenCalledWith(expect.any(Object), {
+ memberId: member.id,
+ expiresAt: null,
+ });
+ });
+
+ it('displays toast when successful', async () => {
+ resolveUpdateMemberExpiration();
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Expiration date removed successfully.');
+ });
+
+ it('disables datepicker while waiting for `updateMemberExpiration` to resolve', async () => {
+ expect(findDatepicker().props('disabled')).toBe(true);
+
+ resolveUpdateMemberExpiration();
+ await waitForPromises();
+
+ expect(findDatepicker().props('disabled')).toBe(false);
+ });
+ });
+
+ describe('when user does not have `canUpdate` permissions', () => {
+ it('disables datepicker', () => {
+ createComponent({ permissions: { canUpdate: false } });
+
+ expect(findDatepicker().props('disabled')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
index 20c1c26d2ee..e593e88438c 100644
--- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js
+++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
@@ -3,14 +3,16 @@ import Vuex from 'vuex';
import {
getByText as getByTextHelper,
getByTestId as getByTestIdHelper,
+ within,
} from '@testing-library/dom';
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlTable } from '@gitlab/ui';
import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue';
+import ExpirationDatepicker from '~/vue_shared/components/members/table/expiration_datepicker.vue';
import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
import * as initUserPopovers from '~/user_popovers';
import { member as memberMock, invite, accessRequest } from '../mock_data';
@@ -26,7 +28,12 @@ describe('MemberList', () => {
state: {
members: [],
tableFields: [],
+ tableAttrs: {
+ table: { 'data-qa-selector': 'members_list' },
+ tr: { 'data-qa-selector': 'member_row' },
+ },
sourceId: 1,
+ currentUserId: 1,
...state,
},
});
@@ -44,6 +51,7 @@ describe('MemberList', () => {
'member-action-buttons',
'role-dropdown',
'remove-group-link-modal',
+ 'expiration-datepicker',
],
});
};
@@ -54,18 +62,24 @@ describe('MemberList', () => {
const getByTestId = (id, options) =>
createWrapper(getByTestIdHelper(wrapper.element, id, options));
+ const findTable = () => wrapper.find(GlTable);
+
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('fields', () => {
- const memberCanUpdate = {
+ const directMember = {
...memberMock,
- canUpdate: true,
source: { ...memberMock.source, id: 1 },
};
+ const memberCanUpdate = {
+ ...directMember,
+ canUpdate: true,
+ };
+
it.each`
field | label | member | expectedComponent
${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
@@ -75,7 +89,7 @@ describe('MemberList', () => {
${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
- ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${ExpirationDatepicker}
`('renders the $label field', ({ field, label, member, expectedComponent }) => {
createComponent({
members: [member],
@@ -94,19 +108,60 @@ describe('MemberList', () => {
}
});
- it('renders "Actions" field for screen readers', () => {
- createComponent({ members: [memberMock], tableFields: ['actions'] });
+ describe('"Actions" field', () => {
+ it('renders "Actions" field for screen readers', () => {
+ createComponent({ members: [memberCanUpdate], tableFields: ['actions'] });
- const actionField = getByTestId('col-actions');
+ const actionField = getByTestId('col-actions');
- expect(actionField.exists()).toBe(true);
- expect(actionField.classes('gl-sr-only')).toBe(true);
- expect(
- wrapper
- .find(`[data-label="Actions"][role="cell"]`)
- .find(MemberActionButtons)
- .exists(),
- ).toBe(true);
+ expect(actionField.exists()).toBe(true);
+ expect(actionField.classes('gl-sr-only')).toBe(true);
+ expect(
+ wrapper
+ .find(`[data-label="Actions"][role="cell"]`)
+ .find(MemberActionButtons)
+ .exists(),
+ ).toBe(true);
+ });
+
+ describe('when user is not logged in', () => {
+ it('does not render the "Actions" field', () => {
+ createComponent({ currentUserId: null, tableFields: ['actions'] });
+
+ expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null);
+ });
+ });
+
+ const memberCanRemove = {
+ ...directMember,
+ canRemove: true,
+ };
+
+ describe.each`
+ permission | members
+ ${'canUpdate'} | ${[memberCanUpdate]}
+ ${'canRemove'} | ${[memberCanRemove]}
+ ${'canResend'} | ${[invite]}
+ `('when one of the members has $permission permissions', ({ members }) => {
+ it('renders the "Actions" field', () => {
+ createComponent({ members, tableFields: ['actions'] });
+
+ expect(getByTestId('col-actions').exists()).toBe(true);
+ });
+ });
+
+ describe.each`
+ permission | members
+ ${'canUpdate'} | ${[memberMock]}
+ ${'canRemove'} | ${[memberMock]}
+ ${'canResend'} | ${[{ ...invite, invite: { ...invite.invite, canResend: false } }]}
+ `('when none of the members have $permission permissions', ({ members }) => {
+ it('does not render the "Actions" field', () => {
+ createComponent({ members, tableFields: ['actions'] });
+
+ expect(within(wrapper.element).queryByTestId('col-actions')).toBe(null);
+ });
+ });
});
});
@@ -138,4 +193,20 @@ describe('MemberList', () => {
expect(initUserPopoversMock).toHaveBeenCalled();
});
+
+ it('adds QA selector to table', () => {
+ createComponent();
+
+ expect(findTable().attributes('data-qa-selector')).toBe('members_list');
+ });
+
+ it('adds QA selector to table row', () => {
+ createComponent();
+
+ expect(
+ findTable()
+ .find('tbody tr')
+ .attributes('data-qa-selector'),
+ ).toBe('member_row');
+ });
});
diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
index 1e47953a510..55ec7000693 100644
--- a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
@@ -30,6 +30,7 @@ describe('RoleDropdown', () => {
wrapper = mount(RoleDropdown, {
propsData: {
member,
+ permissions: {},
...propsData,
},
localVue,
@@ -115,11 +116,11 @@ describe('RoleDropdown', () => {
await nextTick();
- expect(findDropdown().attributes('disabled')).toBe('disabled');
+ expect(findDropdown().props('disabled')).toBe(true);
await waitForPromises();
- expect(findDropdown().attributes('disabled')).toBeUndefined();
+ expect(findDropdown().props('disabled')).toBe(false);
});
});
});
diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js
index f183abc08d6..3f2b2097133 100644
--- a/spec/frontend/vue_shared/components/members/utils_spec.js
+++ b/spec/frontend/vue_shared/components/members/utils_spec.js
@@ -1,5 +1,19 @@
-import { generateBadges } from '~/vue_shared/components/members/utils';
-import { member as memberMock } from './mock_data';
+import {
+ generateBadges,
+ isGroup,
+ isDirectMember,
+ isCurrentUser,
+ canRemove,
+ canResend,
+ canUpdate,
+ canOverride,
+} from '~/vue_shared/components/members/utils';
+import { member as memberMock, group, invite } from './mock_data';
+
+const DIRECT_MEMBER_ID = 178;
+const INHERITED_MEMBER_ID = 179;
+const IS_CURRENT_USER_ID = 123;
+const IS_NOT_CURRENT_USER_ID = 124;
describe('Members Utils', () => {
describe('generateBadges', () => {
@@ -26,4 +40,83 @@ describe('Members Utils', () => {
expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
});
});
+
+ describe('isGroup', () => {
+ test.each`
+ member | expected
+ ${group} | ${true}
+ ${memberMock} | ${false}
+ `('returns $expected', ({ member, expected }) => {
+ expect(isGroup(member)).toBe(expected);
+ });
+ });
+
+ describe('isDirectMember', () => {
+ test.each`
+ sourceId | expected
+ ${DIRECT_MEMBER_ID} | ${true}
+ ${INHERITED_MEMBER_ID} | ${false}
+ `('returns $expected', ({ sourceId, expected }) => {
+ expect(isDirectMember(memberMock, sourceId)).toBe(expected);
+ });
+ });
+
+ describe('isCurrentUser', () => {
+ test.each`
+ currentUserId | expected
+ ${IS_CURRENT_USER_ID} | ${true}
+ ${IS_NOT_CURRENT_USER_ID} | ${false}
+ `('returns $expected', ({ currentUserId, expected }) => {
+ expect(isCurrentUser(memberMock, currentUserId)).toBe(expected);
+ });
+ });
+
+ describe('canRemove', () => {
+ const memberCanRemove = {
+ ...memberMock,
+ canRemove: true,
+ };
+
+ test.each`
+ member | sourceId | expected
+ ${memberCanRemove} | ${DIRECT_MEMBER_ID} | ${true}
+ ${memberCanRemove} | ${INHERITED_MEMBER_ID} | ${false}
+ ${memberMock} | ${INHERITED_MEMBER_ID} | ${false}
+ `('returns $expected', ({ member, sourceId, expected }) => {
+ expect(canRemove(member, sourceId)).toBe(expected);
+ });
+ });
+
+ describe('canResend', () => {
+ test.each`
+ member | expected
+ ${invite} | ${true}
+ ${{ ...invite, invite: { ...invite.invite, canResend: false } }} | ${false}
+ `('returns $expected', ({ member, sourceId, expected }) => {
+ expect(canResend(member, sourceId)).toBe(expected);
+ });
+ });
+
+ describe('canUpdate', () => {
+ const memberCanUpdate = {
+ ...memberMock,
+ canUpdate: true,
+ };
+
+ test.each`
+ member | currentUserId | sourceId | expected
+ ${memberCanUpdate} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${true}
+ ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false}
+ ${memberCanUpdate} | ${IS_CURRENT_USER_ID} | ${INHERITED_MEMBER_ID} | ${false}
+ ${memberMock} | ${IS_NOT_CURRENT_USER_ID} | ${DIRECT_MEMBER_ID} | ${false}
+ `('returns $expected', ({ member, currentUserId, sourceId, expected }) => {
+ expect(canUpdate(member, currentUserId, sourceId)).toBe(expected);
+ });
+ });
+
+ describe('canOverride', () => {
+ it('returns `false`', () => {
+ expect(canOverride(memberMock)).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/modal_copy_button_spec.js b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
index e5a8860f42e..ca9f8ff54d4 100644
--- a/spec/frontend/vue_shared/components/modal_copy_button_spec.js
+++ b/spec/frontend/vue_shared/components/modal_copy_button_spec.js
@@ -1,9 +1,7 @@
-import Vue from 'vue';
-import { shallowMount } from '@vue/test-utils';
-import modalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
+import { shallowMount, createWrapper } from '@vue/test-utils';
+import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue';
describe('modal copy button', () => {
- const Component = Vue.extend(modalCopyButton);
let wrapper;
afterEach(() => {
@@ -11,16 +9,18 @@ describe('modal copy button', () => {
});
beforeEach(() => {
- wrapper = shallowMount(Component, {
+ wrapper = shallowMount(ModalCopyButton, {
propsData: {
text: 'copy me',
title: 'Copy this value',
+ id: 'test-id',
},
});
});
describe('clipboard', () => {
it('should fire a `success` event on click', () => {
+ const root = createWrapper(wrapper.vm.$root);
document.execCommand = jest.fn(() => true);
window.getSelection = jest.fn(() => ({
toString: jest.fn(() => 'test'),
@@ -31,6 +31,7 @@ describe('modal copy button', () => {
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted().success).not.toBeEmpty();
expect(document.execCommand).toHaveBeenCalledWith('copy');
+ expect(root.emitted('bv::hide::tooltip')).toEqual([['test-id']]);
});
});
it("should propagate the clipboard error event if execCommand doesn't work", () => {
diff --git a/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js
new file mode 100644
index 00000000000..233c488b60b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/multiselect_dropdown_spec.js
@@ -0,0 +1,31 @@
+import { shallowMount } from '@vue/test-utils';
+import { getByText } from '@testing-library/dom';
+import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
+
+describe('MultiSelectDropdown Component', () => {
+ it('renders items slot', () => {
+ const wrapper = shallowMount(MultiSelectDropdown, {
+ propsData: {
+ text: '',
+ headerText: '',
+ },
+ slots: {
+ items: '<p>Test</p>',
+ },
+ });
+ expect(getByText(wrapper.element, 'Test')).toBeDefined();
+ });
+
+ it('renders search slot', () => {
+ const wrapper = shallowMount(MultiSelectDropdown, {
+ propsData: {
+ text: '',
+ headerText: '',
+ },
+ slots: {
+ search: '<p>Search</p>',
+ },
+ });
+ expect(getByText(wrapper.element, 'Search')).toBeDefined();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index d943aaf3e5f..0f7c8e97635 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -70,7 +70,7 @@ describe('AlertManagementEmptyState', () => {
...props,
},
slots: {
- 'emtpy-state': EmptyStateSlot,
+ 'empty-state': EmptyStateSlot,
'header-actions': HeaderActionsSlot,
title: TitleSlot,
table: TableSlot,
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
index 5cb606b58d9..b743a663f06 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -5,12 +5,16 @@ import component from '~/vue_shared/components/registry/title_area.vue';
describe('title area', () => {
let wrapper;
+ const DYNAMIC_SLOT = 'metadata-dynamic-slot';
+
const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]');
const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]');
const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`);
const findTitle = () => wrapper.find('[data-testid="title"]');
const findAvatar = () => wrapper.find(GlAvatar);
const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]');
+ const findDynamicSlot = () => wrapper.find(`[data-testid="${DYNAMIC_SLOT}`);
+ const findSlotOrderElements = () => wrapper.findAll('[slot-test]');
const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => {
wrapper = shallowMount(component, {
@@ -98,6 +102,59 @@ describe('title area', () => {
});
});
+ describe('dynamic slots', () => {
+ const createDynamicSlot = () => {
+ return wrapper.vm.$createElement('div', {
+ attrs: {
+ 'data-testid': DYNAMIC_SLOT,
+ 'slot-test': true,
+ },
+ });
+ };
+ it('shows dynamic slots', async () => {
+ mountComponent();
+ // we manually add a new slot to simulate dynamic slots being evaluated after the initial mount
+ wrapper.vm.$slots[DYNAMIC_SLOT] = createDynamicSlot();
+
+ await wrapper.vm.$nextTick();
+ expect(findDynamicSlot().exists()).toBe(false);
+
+ await wrapper.vm.$nextTick();
+ expect(findDynamicSlot().exists()).toBe(true);
+ });
+
+ it('preserve the order of the slots', async () => {
+ mountComponent({
+ slots: {
+ 'metadata-foo': '<div slot-test data-testid="metadata-foo"></div>',
+ },
+ });
+
+ // rewrite slot putting dynamic slot as first
+ wrapper.vm.$slots = {
+ 'metadata-dynamic-slot': createDynamicSlot(),
+ 'metadata-foo': wrapper.vm.$slots['metadata-foo'],
+ };
+
+ await wrapper.vm.$nextTick();
+ expect(findDynamicSlot().exists()).toBe(false);
+ expect(findMetadataSlot('metadata-foo').exists()).toBe(true);
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ findSlotOrderElements()
+ .at(0)
+ .attributes('data-testid'),
+ ).toBe(DYNAMIC_SLOT);
+ expect(
+ findSlotOrderElements()
+ .at(1)
+ .attributes('data-testid'),
+ ).toBe('metadata-foo');
+ });
+ });
+
describe('info-messages', () => {
it('shows a message when the props contains one', () => {
mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } });
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
index 0f2f263a776..d79df4d0557 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
@@ -91,12 +91,25 @@ describe('Editor Service', () => {
});
describe('addImage', () => {
- it('calls the exec method on the instance', () => {
- const mockImage = { imageUrl: 'some/url.png', description: 'some description' };
+ const file = new File([], 'some-file.jpg');
+ const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' };
- addImage(mockInstance, mockImage);
+ it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => {
+ jest.spyOn(URL, 'createObjectURL');
+ mockInstance.editor.isWysiwygMode.mockReturnValue(true);
+ mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() });
- expect(mockInstance.editor.exec).toHaveBeenCalledWith('AddImage', mockImage);
+ addImage(mockInstance, mockImage, file);
+
+ expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled();
+ expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file);
+ });
+
+ it('calls the insertText method on the instance when in Markdown mode', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(false);
+ addImage(mockInstance, mockImage, file);
+
+ expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)');
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js
index 0c2ac53aa52..16370a7aaad 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js
@@ -15,10 +15,7 @@ describe('Add Image Modal', () => {
const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' });
beforeEach(() => {
- wrapper = shallowMount(AddImageModal, {
- provide: { glFeatures: { sseImageUploads: true } },
- propsData,
- });
+ wrapper = shallowMount(AddImageModal, { propsData });
});
describe('when content is loaded', () => {
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
index 8c2c0413819..d50cf2915e8 100644
--- 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
@@ -180,7 +180,7 @@ describe('Rich Content Editor', () => {
wrapper.vm.$refs.editor = mockInstance;
findAddImageModal().vm.$emit('addImage', mockImage);
- expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage);
+ expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined);
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
index fd745c21bb6..85516eae4cf 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js
@@ -189,4 +189,30 @@ describe('rich_content_editor/services/html_to_markdown_renderer', () => {
expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult);
});
});
+
+ describe('IMG', () => {
+ const originalSrc = 'path/to/image.png';
+ const alt = 'alt text';
+ let node;
+
+ beforeEach(() => {
+ node = document.createElement('img');
+ node.alt = alt;
+ node.src = originalSrc;
+ });
+
+ it('returns an image with its original src of the `original-src` attribute is preset', () => {
+ node.dataset.originalSrc = originalSrc;
+ node.src = 'modified/path/to/image.png';
+
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+
+ it('fallback to `src` if no `original-src` is specified on the image', () => {
+ htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer);
+ expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js
new file mode 100644
index 00000000000..01f7f3d49c7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js
@@ -0,0 +1,107 @@
+export const mockGraphqlRunnerPlatforms = {
+ data: {
+ runnerPlatforms: {
+ nodes: [
+ {
+ name: 'linux',
+ humanReadableName: 'Linux',
+ architectures: {
+ nodes: [
+ {
+ name: 'amd64',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64',
+ __typename: 'RunnerArchitecture',
+ },
+ {
+ name: '386',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386',
+ __typename: 'RunnerArchitecture',
+ },
+ {
+ name: 'arm',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm',
+ __typename: 'RunnerArchitecture',
+ },
+ {
+ name: 'arm64',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64',
+ __typename: 'RunnerArchitecture',
+ },
+ ],
+ __typename: 'RunnerArchitectureConnection',
+ },
+ __typename: 'RunnerPlatform',
+ },
+ {
+ name: 'osx',
+ humanReadableName: 'macOS',
+ architectures: {
+ nodes: [
+ {
+ name: 'amd64',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64',
+ __typename: 'RunnerArchitecture',
+ },
+ ],
+ __typename: 'RunnerArchitectureConnection',
+ },
+ __typename: 'RunnerPlatform',
+ },
+ {
+ name: 'windows',
+ humanReadableName: 'Windows',
+ architectures: {
+ nodes: [
+ {
+ name: 'amd64',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe',
+ __typename: 'RunnerArchitecture',
+ },
+ {
+ name: '386',
+ downloadLocation:
+ 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe',
+ __typename: 'RunnerArchitecture',
+ },
+ ],
+ __typename: 'RunnerArchitectureConnection',
+ },
+ __typename: 'RunnerPlatform',
+ },
+ {
+ name: 'docker',
+ humanReadableName: 'Docker',
+ architectures: null,
+ __typename: 'RunnerPlatform',
+ },
+ {
+ name: 'kubernetes',
+ humanReadableName: 'Kubernetes',
+ architectures: null,
+ __typename: 'RunnerPlatform',
+ },
+ ],
+ __typename: 'RunnerPlatformConnection',
+ },
+ project: { id: 'gid://gitlab/Project/1', __typename: 'Project' },
+ group: null,
+ },
+};
+
+export const mockGraphqlInstructions = {
+ data: {
+ runnerSetup: {
+ installInstructions:
+ "# Download the binary for your system\nsudo curl -L --output /usr/local/bin/gitlab-runner https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64\n\n# Give it permissions to execute\nsudo chmod +x /usr/local/bin/gitlab-runner\n\n# Create a GitLab CI user\nsudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash\n\n# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start\n",
+ registerInstructions:
+ 'sudo gitlab-runner register --url http://192.168.1.81:3000/ --registration-token GE5gsjeep_HAtBf9s3Yz',
+ __typename: 'RunnerSetup',
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
new file mode 100644
index 00000000000..afbcee506c7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_spec.js
@@ -0,0 +1,119 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
+import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
+import getRunnerPlatforms from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_platforms.query.graphql';
+import getRunnerSetupInstructions from '~/vue_shared/components/runner_instructions/graphql/queries/get_runner_setup.query.graphql';
+
+import { mockGraphqlRunnerPlatforms, mockGraphqlInstructions } from './mock_data';
+
+const projectPath = 'gitlab-org/gitlab';
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('RunnerInstructions component', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const findModalButton = () => wrapper.find('[data-testid="show-modal-button"]');
+ const findPlatformButtons = () => wrapper.findAll('[data-testid="platform-button"]');
+ const findArchitectureDropdownItems = () =>
+ wrapper.findAll('[data-testid="architecture-dropdown-item"]');
+ const findBinaryInstructionsSection = () => wrapper.find('[data-testid="binary-instructions"]');
+ const findRunnerInstructionsSection = () => wrapper.find('[data-testid="runner-instructions"]');
+
+ beforeEach(() => {
+ const requestHandlers = [
+ [getRunnerPlatforms, jest.fn().mockResolvedValue(mockGraphqlRunnerPlatforms)],
+ [getRunnerSetupInstructions, jest.fn().mockResolvedValue(mockGraphqlInstructions)],
+ ];
+
+ fakeApollo = createMockApollo(requestHandlers);
+
+ wrapper = shallowMount(RunnerInstructions, {
+ provide: {
+ projectPath,
+ },
+ localVue,
+ apolloProvider: fakeApollo,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('should show the "Show Runner installation instructions" button', () => {
+ const button = findModalButton();
+
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toBe('Show Runner installation instructions');
+ });
+
+ it('should contain a number of platforms buttons', () => {
+ const buttons = findPlatformButtons();
+
+ expect(buttons).toHaveLength(mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes.length);
+ });
+
+ it('should contain a number of dropdown items for the architecture options', () => {
+ const platformButton = findPlatformButtons().at(0);
+ platformButton.vm.$emit('click');
+
+ return wrapper.vm.$nextTick(() => {
+ const dropdownItems = findArchitectureDropdownItems();
+
+ expect(dropdownItems).toHaveLength(
+ mockGraphqlRunnerPlatforms.data.runnerPlatforms.nodes[0].architectures.nodes.length,
+ );
+ });
+ });
+
+ it('should display the binary installation instructions for a selected architecture', async () => {
+ const platformButton = findPlatformButtons().at(0);
+ platformButton.vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ const dropdownItem = findArchitectureDropdownItems().at(0);
+ dropdownItem.vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ const runner = findBinaryInstructionsSection();
+
+ expect(runner.text()).toEqual(
+ expect.stringContaining('sudo chmod +x /usr/local/bin/gitlab-runner'),
+ );
+ expect(runner.text()).toEqual(
+ expect.stringContaining(
+ `sudo useradd --comment 'GitLab Runner' --create-home gitlab-runner --shell /bin/bash`,
+ ),
+ );
+ expect(runner.text()).toEqual(
+ expect.stringContaining(
+ 'sudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner',
+ ),
+ );
+ expect(runner.text()).toEqual(expect.stringContaining('sudo gitlab-runner start'));
+ });
+
+ it('should display the runner register instructions for a selected architecture', async () => {
+ const platformButton = findPlatformButtons().at(0);
+ platformButton.vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ const dropdownItem = findArchitectureDropdownItems().at(0);
+ dropdownItem.vm.$emit('click');
+
+ await wrapper.vm.$nextTick();
+
+ const runner = findRunnerInstructionsSection();
+
+ expect(runner.text()).toEqual(
+ expect.stringContaining(mockGraphqlInstructions.data.runnerSetup.registerInstructions),
+ );
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js
new file mode 100644
index 00000000000..a97e26caf53
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js
@@ -0,0 +1,375 @@
+import { shallowMount } from '@vue/test-utils';
+import MockAdapter from 'axios-mock-adapter';
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlDropdown,
+ GlDropdownForm,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlButton,
+} from '@gitlab/ui';
+
+import axios from '~/lib/utils/axios_utils';
+import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue';
+
+const mockProjects = [
+ {
+ id: 2,
+ name_with_namespace: 'Gitlab Org / Gitlab Shell',
+ full_path: 'gitlab-org/gitlab-shell',
+ },
+ {
+ id: 3,
+ name_with_namespace: 'Gnuwget / Wget2',
+ full_path: 'gnuwget/wget2',
+ },
+ {
+ id: 4,
+ name_with_namespace: 'Commit451 / Lab Coat',
+ full_path: 'Commit451/lab-coat',
+ },
+];
+
+const mockProps = {
+ projectsFetchPath: '/-/autocomplete/projects?project_id=1',
+ dropdownButtonTitle: 'Move issuable',
+ dropdownHeaderTitle: 'Move issuable',
+ moveInProgress: false,
+};
+
+const mockEvent = {
+ stopPropagation: jest.fn(),
+ preventDefault: jest.fn(),
+};
+
+const createComponent = (propsData = mockProps) =>
+ shallowMount(IssuableMoveDropdown, {
+ propsData,
+ });
+
+describe('IssuableMoveDropdown', () => {
+ let mock;
+ let wrapper;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ wrapper = createComponent();
+ wrapper.vm.$refs.dropdown.hide = jest.fn();
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ mock.restore();
+ });
+
+ describe('watch', () => {
+ describe('searchKey', () => {
+ it('calls `fetchProjects` with value of the prop', async () => {
+ jest.spyOn(wrapper.vm, 'fetchProjects');
+ wrapper.setData({
+ searchKey: 'foo',
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.fetchProjects).toHaveBeenCalledWith('foo');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('fetchProjects', () => {
+ it('sets projectsListLoading to true and projectsListLoadFailed to false', () => {
+ wrapper.vm.fetchProjects();
+
+ expect(wrapper.vm.projectsListLoading).toBe(true);
+ expect(wrapper.vm.projectsListLoadFailed).toBe(false);
+ });
+
+ it('calls `axios.get` with `projectsFetchPath` and query param `search`', () => {
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ data: mockProjects,
+ });
+
+ wrapper.vm.fetchProjects('foo');
+
+ expect(axios.get).toHaveBeenCalledWith(
+ mockProps.projectsFetchPath,
+ expect.objectContaining({
+ params: {
+ search: 'foo',
+ },
+ }),
+ );
+ });
+
+ it('sets response to `projects` and focuses on searchInput when request is successful', async () => {
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ data: mockProjects,
+ });
+
+ await wrapper.vm.fetchProjects('foo');
+
+ expect(wrapper.vm.projects).toBe(mockProjects);
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+
+ it('sets projectsListLoadFailed to true when request fails', async () => {
+ jest.spyOn(axios, 'get').mockRejectedValue({});
+
+ await wrapper.vm.fetchProjects('foo');
+
+ expect(wrapper.vm.projectsListLoadFailed).toBe(true);
+ });
+
+ it('sets projectsListLoading to false when request completes', async () => {
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ data: mockProjects,
+ });
+
+ await wrapper.vm.fetchProjects('foo');
+
+ expect(wrapper.vm.projectsListLoading).toBe(false);
+ });
+ });
+
+ describe('isSelectedProject', () => {
+ it.each`
+ project | selectedProject | title | returnValue
+ ${mockProjects[0]} | ${mockProjects[0]} | ${'are same projects'} | ${true}
+ ${mockProjects[0]} | ${mockProjects[1]} | ${'are different projects'} | ${false}
+ `(
+ 'returns $returnValue when selectedProject and provided project param $title',
+ async ({ project, selectedProject, returnValue }) => {
+ wrapper.setData({
+ selectedProject,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.isSelectedProject(project)).toBe(returnValue);
+ },
+ );
+
+ it('returns false when selectedProject is null', async () => {
+ wrapper.setData({
+ selectedProject: null,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.isSelectedProject(mockProjects[0])).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ const findDropdownEl = () => wrapper.find(GlDropdown);
+
+ it('renders collapsed state element with icon', () => {
+ const collapsedEl = wrapper.find('[data-testid="move-collapsed"]');
+
+ expect(collapsedEl.exists()).toBe(true);
+ expect(collapsedEl.attributes('title')).toBe(mockProps.dropdownButtonTitle);
+ expect(collapsedEl.find(GlIcon).exists()).toBe(true);
+ expect(collapsedEl.find(GlIcon).props('name')).toBe('arrow-right');
+ });
+
+ describe('gl-dropdown component', () => {
+ it('renders component container element', () => {
+ expect(findDropdownEl().exists()).toBe(true);
+ expect(findDropdownEl().props('block')).toBe(true);
+ });
+
+ it('renders gl-dropdown-form component', () => {
+ expect(
+ findDropdownEl()
+ .find(GlDropdownForm)
+ .exists(),
+ ).toBe(true);
+ });
+
+ it('renders header element', () => {
+ const headerEl = findDropdownEl().find('[data-testid="header"]');
+
+ expect(headerEl.exists()).toBe(true);
+ expect(headerEl.find('span').text()).toBe(mockProps.dropdownHeaderTitle);
+ expect(headerEl.find(GlButton).props('icon')).toBe('close');
+ });
+
+ it('renders gl-search-box-by-type component', () => {
+ const searchEl = findDropdownEl().find(GlSearchBoxByType);
+
+ expect(searchEl.exists()).toBe(true);
+ expect(searchEl.attributes()).toMatchObject({
+ placeholder: 'Search project',
+ debounce: '300',
+ });
+ });
+
+ it('renders gl-loading-icon component when projectsListLoading prop is true', async () => {
+ wrapper.setData({
+ projectsListLoading: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ findDropdownEl()
+ .find(GlLoadingIcon)
+ .exists(),
+ ).toBe(true);
+ });
+
+ it('renders gl-dropdown-item components for available projects', async () => {
+ wrapper.setData({
+ projects: mockProjects,
+ selectedProject: mockProjects[0],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const dropdownItems = wrapper.findAll(GlDropdownItem);
+
+ expect(dropdownItems).toHaveLength(mockProjects.length);
+ expect(dropdownItems.at(0).props()).toMatchObject({
+ isCheckItem: true,
+ isChecked: true,
+ });
+ expect(dropdownItems.at(0).text()).toBe(mockProjects[0].name_with_namespace);
+ });
+
+ it('renders string "No matching results" when search does not yield any matches', async () => {
+ wrapper.setData({
+ searchKey: 'foo',
+ });
+
+ // Wait for `searchKey` watcher to run.
+ await wrapper.vm.$nextTick();
+
+ wrapper.setData({
+ projects: [],
+ projectsListLoading: false,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const dropdownContentEl = wrapper.find('[data-testid="content"]');
+
+ expect(dropdownContentEl.text()).toContain('No matching results');
+ });
+
+ it('renders string "Failed to load projects" when loading projects list fails', async () => {
+ wrapper.setData({
+ projects: [],
+ projectsListLoading: false,
+ projectsListLoadFailed: true,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ const dropdownContentEl = wrapper.find('[data-testid="content"]');
+
+ expect(dropdownContentEl.text()).toContain('Failed to load projects');
+ });
+
+ it('renders gl-button within footer', async () => {
+ const moveButtonEl = wrapper.find('[data-testid="footer"]').find(GlButton);
+
+ expect(moveButtonEl.text()).toBe('Move');
+ expect(moveButtonEl.attributes('disabled')).toBe('true');
+
+ wrapper.setData({
+ selectedProject: mockProjects[0],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(
+ wrapper
+ .find('[data-testid="footer"]')
+ .find(GlButton)
+ .attributes('disabled'),
+ ).not.toBeDefined();
+ });
+ });
+
+ describe('events', () => {
+ it('collapsed state element emits `toggle-collapse` event on component when clicked', () => {
+ wrapper.find('[data-testid="move-collapsed"]').trigger('click');
+
+ expect(wrapper.emitted('toggle-collapse')).toBeTruthy();
+ });
+
+ it('gl-dropdown component calls `fetchProjects` on `shown` event', () => {
+ jest.spyOn(axios, 'get').mockResolvedValue({
+ data: mockProjects,
+ });
+
+ findDropdownEl().vm.$emit('shown');
+
+ expect(axios.get).toHaveBeenCalled();
+ });
+
+ it('gl-dropdown component prevents dropdown body from closing on `hide` event when `projectItemClick` prop is true', async () => {
+ wrapper.setData({
+ projectItemClick: true,
+ });
+
+ findDropdownEl().vm.$emit('hide', mockEvent);
+
+ expect(mockEvent.preventDefault).toHaveBeenCalled();
+ expect(wrapper.vm.projectItemClick).toBe(false);
+ });
+
+ it('gl-dropdown component emits `dropdown-close` event on component from `hide` event', async () => {
+ findDropdownEl().vm.$emit('hide');
+
+ expect(wrapper.emitted('dropdown-close')).toBeTruthy();
+ });
+
+ it('close icon in dropdown header closes the dropdown when clicked', () => {
+ wrapper
+ .find('[data-testid="header"]')
+ .find(GlButton)
+ .vm.$emit('click', mockEvent);
+
+ expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
+ });
+
+ it('sets project for clicked gl-dropdown-item to selectedProject', async () => {
+ wrapper.setData({
+ projects: mockProjects,
+ });
+
+ await wrapper.vm.$nextTick();
+
+ wrapper
+ .findAll(GlDropdownItem)
+ .at(0)
+ .vm.$emit('click', mockEvent);
+
+ expect(wrapper.vm.selectedProject).toBe(mockProjects[0]);
+ });
+
+ it('hides dropdown and emits `move-issuable` event when move button is clicked', async () => {
+ wrapper.setData({
+ selectedProject: mockProjects[0],
+ });
+
+ await wrapper.vm.$nextTick();
+
+ wrapper
+ .find('[data-testid="footer"]')
+ .find(GlButton)
+ .vm.$emit('click');
+
+ expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalled();
+ expect(wrapper.emitted('move-issuable')).toBeTruthy();
+ expect(wrapper.emitted('move-issuable')[0]).toEqual([mockProjects[0]]);
+ });
+ });
+ });
+});
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 7847e0ee71d..71c040c6633 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
@@ -81,9 +81,7 @@ describe('DropdownValueCollapsedComponent', () => {
describe('template', () => {
it('renders component container element with tooltip`', () => {
- expect(vm.$el.dataset.placement).toBe('left');
- expect(vm.$el.dataset.container).toBe('body');
- expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList);
+ expect(vm.$el.title).toBe(vm.labelsList);
});
it('renders tags icon element', () => {
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 e8a126d8774..78367b3a5b4 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
@@ -128,6 +128,16 @@ describe('DropdownContentsLabelsView', () => {
});
});
+ describe('handleComponentAppear', () => {
+ it('calls `focusInput` on searchInput field', async () => {
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+
+ await wrapper.vm.handleComponentAppear();
+
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+ });
+
describe('handleComponentDisappear', () => {
it('calls action `receiveLabelsSuccess` with empty array', () => {
jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
@@ -301,7 +311,6 @@ describe('DropdownContentsLabelsView', () => {
const searchInputEl = wrapper.find(GlSearchBoxByType);
expect(searchInputEl.exists()).toBe(true);
- expect(searchInputEl.attributes('autofocus')).toBe('true');
});
it('renders label elements for all labels', () => {
diff --git a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
index bc86ee5a0c6..0786882f527 100644
--- a/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
+++ b/spec/frontend/vue_shared/components/stacked_progress_bar_spec.js
@@ -29,6 +29,13 @@ describe('StackedProgressBarComponent', () => {
vm.$destroy();
});
+ const findSuccessBarText = wrapper => wrapper.$el.querySelector('.status-green').innerText.trim();
+ const findNeutralBarText = wrapper =>
+ wrapper.$el.querySelector('.status-neutral').innerText.trim();
+ const findFailureBarText = wrapper => wrapper.$el.querySelector('.status-red').innerText.trim();
+ const findUnavailableBarText = wrapper =>
+ wrapper.$el.querySelector('.status-unavailable').innerText.trim();
+
describe('computed', () => {
describe('neutralCount', () => {
it('returns neutralCount based on totalCount, successCount and failureCount', () => {
@@ -37,24 +44,54 @@ describe('StackedProgressBarComponent', () => {
});
});
- describe('methods', () => {
+ 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(findUnavailableBarText(vmX)).not.toBeUndefined();
+ });
+
+ it('renders bar elements when count is available', () => {
+ expect(findSuccessBarText(vm)).not.toBeUndefined();
+ expect(findNeutralBarText(vm)).not.toBeUndefined();
+ expect(findFailureBarText(vm)).not.toBeUndefined();
+ });
+
describe('getPercent', () => {
- it('returns percentage from provided count based on `totalCount`', () => {
- expect(vm.getPercent(500)).toBe(10);
+ it('returns correct percentages from provided count based on `totalCount`', () => {
+ vm = createComponent({ totalCount: 100, successCount: 25, failureCount: 10 });
+
+ expect(findSuccessBarText(vm)).toBe('25%');
+ expect(findNeutralBarText(vm)).toBe('65%');
+ expect(findFailureBarText(vm)).toBe('10%');
});
- it('returns percentage with decimal place from provided count based on `totalCount`', () => {
- expect(vm.getPercent(67)).toBe(1.3);
+ it('returns percentage with decimal place when decimal is greater than 1', () => {
+ vm = createComponent({ successCount: 67 });
+
+ expect(findSuccessBarText(vm)).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 percentage as `< 1%` from provided count based on `totalCount` when evaluated value is less than 1', () => {
+ vm = createComponent({ successCount: 10 });
+
+ expect(findSuccessBarText(vm)).toBe('< 1%');
});
- it('returns 0 if totalCount is falsy', () => {
+ it('returns not available if totalCount is falsy', () => {
vm = createComponent({ totalCount: 0 });
- expect(vm.getPercent(100)).toBe(0);
+ expect(findUnavailableBarText(vm)).toBe('Not available');
+ });
+
+ it('returns 99.9% when numbers are extreme decimals', () => {
+ vm = createComponent({ totalCount: 1000000 });
+
+ expect(findNeutralBarText(vm)).toBe('99.9%');
});
});
@@ -82,23 +119,4 @@ describe('StackedProgressBarComponent', () => {
});
});
});
-
- 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/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
index 1ca5360fa59..d2fe3cd76cb 100644
--- a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap
+++ b/spec/frontend/vue_shared/components/upload_dropzone/__snapshots__/upload_dropzone_spec.js.snap
@@ -1,11 +1,90 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = `
+exports[`Upload dropzone component correctly overrides description and drop messages 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ >
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
+ data-testid="dropzone-area"
+ >
+ <gl-icon-stub
+ class="gl-mb-2"
+ name="upload"
+ size="24"
+ />
+
+ <p
+ class="gl-mb-0"
+ >
+ <span>
+ Test %{linkStart}description%{linkEnd} message.
+ </span>
+ </p>
+ </div>
+ </button>
+
+ <input
+ accept="image/jpg,image/jpeg"
+ class="hide"
+ multiple="multiple"
+ name="upload_file"
+ type="file"
+ />
+
+ <transition-stub
+ name="upload-dropzone-fade"
+ >
+ <div
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ style="display: none;"
+ >
+ <div
+ class="mw-50 gl-text-center"
+ >
+ <h3
+ class=""
+ >
+
+ 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 gl-text-center"
+ style="display: none;"
+ >
+ <h3
+ class=""
+ >
+
+ Incoming!
+
+ </h3>
+
+ <span>
+ Test drop-to-start message.
+ </span>
+ </div>
+ </div>
+ </transition-stub>
+</div>
+`;
+
+exports[`Upload dropzone component when dragging renders correct template when drag event contains files 1`] = `
+<div
+ class="gl-w-full gl-relative"
+>
+ <button
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -21,7 +100,7 @@ exports[`Design management dropzone component when dragging renders correct temp
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -31,15 +110,15 @@ exports[`Design management dropzone component when dragging renders correct temp
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style=""
>
<div
@@ -49,7 +128,9 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -64,11 +145,13 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -76,12 +159,12 @@ exports[`Design management dropzone component when dragging renders correct temp
</div>
`;
-exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
+exports[`Upload dropzone component when dragging renders correct template when drag event contains files and text 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -97,7 +180,7 @@ exports[`Design management dropzone component when dragging renders correct temp
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -107,15 +190,15 @@ exports[`Design management dropzone component when dragging renders correct temp
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style=""
>
<div
@@ -125,7 +208,9 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -140,11 +225,13 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -152,12 +239,12 @@ exports[`Design management dropzone component when dragging renders correct temp
</div>
`;
-exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = `
+exports[`Upload dropzone component when dragging renders correct template when drag event contains text 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -173,7 +260,7 @@ exports[`Design management dropzone component when dragging renders correct temp
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -183,15 +270,15 @@ exports[`Design management dropzone component when dragging renders correct temp
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style=""
>
<div
@@ -200,7 +287,9 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -215,11 +304,13 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -227,12 +318,12 @@ exports[`Design management dropzone component when dragging renders correct temp
</div>
`;
-exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = `
+exports[`Upload dropzone component when dragging renders correct template when drag event is empty 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -248,7 +339,7 @@ exports[`Design management dropzone component when dragging renders correct temp
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -258,15 +349,15 @@ exports[`Design management dropzone component when dragging renders correct temp
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style=""
>
<div
@@ -275,7 +366,9 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -290,11 +383,13 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -302,12 +397,12 @@ exports[`Design management dropzone component when dragging renders correct temp
</div>
`;
-exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = `
+exports[`Upload dropzone component when dragging renders correct template when dragging stops 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -323,7 +418,7 @@ exports[`Design management dropzone component when dragging renders correct temp
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -333,15 +428,15 @@ exports[`Design management dropzone component when dragging renders correct temp
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style="display: none;"
>
<div
@@ -350,7 +445,9 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -365,11 +462,13 @@ exports[`Design management dropzone component when dragging renders correct temp
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -377,12 +476,12 @@ exports[`Design management dropzone component when dragging renders correct temp
</div>
`;
-exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = `
+exports[`Upload dropzone component when no slot provided renders default dropzone card 1`] = `
<div
class="gl-w-full gl-relative"
>
<button
- class="card design-dropzone-card design-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
+ class="card upload-dropzone-card upload-dropzone-border gl-w-full gl-h-full gl-align-items-center gl-justify-content-center gl-p-3"
>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column"
@@ -398,7 +497,7 @@ exports[`Design management dropzone component when no slot provided renders defa
class="gl-mb-0"
>
<gl-sprintf-stub
- message="Drop or %{linkStart}upload%{linkEnd} designs to attach"
+ message="Drop or %{linkStart}upload%{linkEnd} files to attach"
/>
</p>
</div>
@@ -408,15 +507,15 @@ exports[`Design management dropzone component when no slot provided renders defa
accept="image/*"
class="hide"
multiple="multiple"
- name="design_file"
+ name="upload_file"
type="file"
/>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style="display: none;"
>
<div
@@ -425,7 +524,9 @@ exports[`Design management dropzone component when no slot provided renders defa
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -440,11 +541,13 @@ exports[`Design management dropzone component when no slot provided renders defa
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
@@ -452,7 +555,7 @@ exports[`Design management dropzone component when no slot provided renders defa
</div>
`;
-exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = `
+exports[`Upload dropzone component when slot provided renders dropzone with slot content 1`] = `
<div
class="gl-w-full gl-relative"
>
@@ -461,10 +564,10 @@ exports[`Design management dropzone component when slot provided renders dropzon
</div>
<transition-stub
- name="design-dropzone-fade"
+ name="upload-dropzone-fade"
>
<div
- class="card design-dropzone-border design-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
+ class="card upload-dropzone-border upload-dropzone-overlay gl-w-full gl-h-full gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-3 gl-bg-white"
style="display: none;"
>
<div
@@ -473,7 +576,9 @@ exports[`Design management dropzone component when slot provided renders dropzon
<h3
class=""
>
- Oh no!
+
+ Oh no!
+
</h3>
<span>
@@ -488,11 +593,13 @@ exports[`Design management dropzone component when slot provided renders dropzon
<h3
class=""
>
- Incoming!
+
+ Incoming!
+
</h3>
<span>
- Drop your designs to start your upload.
+ Drop your files to start your upload.
</span>
</div>
</div>
diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
index bf97399368f..11982eb513d 100644
--- a/spec/frontend/design_management/components/upload/design_dropzone_spec.js
+++ b/spec/frontend/vue_shared/components/upload_dropzone/upload_dropzone_spec.js
@@ -1,26 +1,25 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
-import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue';
-import { deprecatedCreateFlash as createFlash } from '~/flash';
+import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue';
jest.mock('~/flash');
-describe('Design management dropzone component', () => {
+describe('Upload dropzone component', () => {
let wrapper;
const mockDragEvent = ({ types = ['Files'], files = [] }) => {
return { dataTransfer: { types, files } };
};
- const findDropzoneCard = () => wrapper.find('.design-dropzone-card');
+ const findDropzoneCard = () => wrapper.find('.upload-dropzone-card');
const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]');
const findIcon = () => wrapper.find(GlIcon);
function createComponent({ slots = {}, data = {}, props = {} } = {}) {
- wrapper = shallowMount(DesignDropzone, {
+ wrapper = shallowMount(UploadDropzone, {
slots,
propsData: {
- hasDesigns: true,
+ displayAsCard: true,
...props,
},
data() {
@@ -126,28 +125,50 @@ describe('Design management dropzone component', () => {
expect(wrapper.emitted().change[0]).toEqual([[mockFile]]);
});
- it('calls createFlash when files are invalid', () => {
+ it('emits error event when files are invalid', () => {
createComponent({ data: mockData });
+ const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
+
+ wrapper.vm.ondrop(mockEvent);
+ expect(wrapper.emitted()).toHaveProperty('error');
+ });
+
+ it('allows validation function to be overwritten', () => {
+ createComponent({ data: mockData, props: { isFileValid: () => true } });
const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] });
wrapper.vm.ondrop(mockEvent);
- expect(createFlash).toHaveBeenCalledTimes(1);
+ expect(wrapper.emitted()).not.toHaveProperty('error');
});
});
});
- it('applies correct classes when there are no designs or no design saving loader', () => {
- createComponent({ props: { hasDesigns: false } });
+ it('applies correct classes when displaying as a standalone item', () => {
+ createComponent({ props: { displayAsCard: false } });
expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column');
expect(findIcon().classes()).toEqual(['gl-mr-3', 'gl-text-gray-500']);
expect(findIcon().props('size')).toBe(16);
});
- it('applies correct classes when there are designs or design saving loader', () => {
- createComponent({ props: { hasDesigns: true } });
+ it('applies correct classes when displaying in card mode', () => {
+ createComponent({ props: { displayAsCard: true } });
expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column');
expect(findIcon().classes()).toEqual(['gl-mb-2']);
expect(findIcon().props('size')).toBe(24);
});
+
+ it('correctly overrides description and drop messages', () => {
+ createComponent({
+ props: {
+ dropToStartMessage: 'Test drop-to-start message.',
+ validFileMimetypes: ['image/jpg', 'image/jpeg'],
+ },
+ slots: {
+ 'upload-text': '<span>Test %{linkStart}description%{linkEnd} message.</span>',
+ },
+ });
+
+ expect(wrapper.element).toMatchSnapshot();
+ });
});
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index c208d7b0226..7d58a865ba3 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -1,6 +1,8 @@
import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue';
+import UserAvailabilityStatus from '~/set_status_modal/components/user_availability_status.vue';
+import { AVAILABILITY_STATUS } from '~/set_status_modal/utils';
const DEFAULT_PROPS = {
user: {
@@ -34,6 +36,7 @@ describe('User Popover Component', () => {
const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`);
const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link');
+ const findAvailabilityStatus = () => wrapper.find(UserAvailabilityStatus);
const createWrapper = (props = {}, options = {}) => {
wrapper = shallowMount(UserPopover, {
@@ -43,7 +46,8 @@ describe('User Popover Component', () => {
...props,
},
stubs: {
- 'gl-sprintf': GlSprintf,
+ GlSprintf,
+ UserAvailabilityStatus,
},
...options,
});
@@ -199,6 +203,30 @@ describe('User Popover Component', () => {
expect(findUserStatus().exists()).toBe(false);
});
+
+ it('should show the busy status if user set to busy', () => {
+ const user = {
+ ...DEFAULT_PROPS.user,
+ status: { availability: AVAILABILITY_STATUS.BUSY },
+ };
+
+ createWrapper({ user });
+
+ expect(findAvailabilityStatus().exists()).toBe(true);
+ expect(wrapper.text()).toContain(user.name);
+ expect(wrapper.text()).toContain('(Busy)');
+ });
+
+ it('should hide the busy status for any other status', () => {
+ const user = {
+ ...DEFAULT_PROPS.user,
+ status: { availability: AVAILABILITY_STATUS.NOT_SET },
+ };
+
+ createWrapper({ user });
+
+ expect(wrapper.text()).not.toContain('(Busy)');
+ });
});
describe('security bot', () => {
diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js
new file mode 100644
index 00000000000..814d6f43589
--- /dev/null
+++ b/spec/frontend/vue_shared/directives/validation_spec.js
@@ -0,0 +1,132 @@
+import { shallowMount } from '@vue/test-utils';
+import validation from '~/vue_shared/directives/validation';
+
+describe('validation directive', () => {
+ let wrapper;
+
+ const createComponent = ({ inputAttributes, showValidation } = {}) => {
+ const defaultInputAttributes = {
+ type: 'text',
+ required: true,
+ };
+
+ const component = {
+ directives: {
+ validation: validation(),
+ },
+ data() {
+ return {
+ attributes: inputAttributes || defaultInputAttributes,
+ showValidation,
+ form: {
+ state: null,
+ fields: {
+ exampleField: {
+ state: null,
+ feedback: '',
+ },
+ },
+ },
+ };
+ },
+ template: `
+ <form>
+ <input v-validation:[showValidation] name="exampleField" v-bind="attributes" />
+ </form>
+ `,
+ };
+
+ wrapper = shallowMount(component, { attachToDocument: true });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const getFormData = () => wrapper.vm.form;
+ const findForm = () => wrapper.find('form');
+ const findInput = () => wrapper.find('input');
+
+ describe.each([true, false])(
+ 'with fields untouched and "showValidation" set to "%s"',
+ showValidation => {
+ beforeEach(() => {
+ createComponent({ showValidation });
+ });
+
+ it('sets the fields validity correctly', () => {
+ expect(getFormData().fields.exampleField).toEqual({
+ state: showValidation ? false : null,
+ feedback: showValidation ? expect.any(String) : '',
+ });
+ });
+
+ it('sets the form validity correctly', () => {
+ expect(getFormData().state).toBe(false);
+ });
+ },
+ );
+
+ describe.each`
+ inputAttributes | validValue | invalidValue
+ ${{ required: true }} | ${'foo'} | ${''}
+ ${{ type: 'url' }} | ${'http://foo.com'} | ${'foo'}
+ ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${0}
+ ${{ type: 'number', min: 1, max: 5 }} | ${3} | ${6}
+ ${{ pattern: 'foo|bar' }} | ${'bar'} | ${'quz'}
+ `(
+ 'with input-attributes set to $inputAttributes',
+ ({ inputAttributes, validValue, invalidValue }) => {
+ const setValueAndTriggerValidation = value => {
+ const input = findInput();
+ input.setValue(value);
+ input.trigger('blur');
+ };
+
+ beforeEach(() => {
+ createComponent({ inputAttributes });
+ });
+
+ describe('with valid value', () => {
+ beforeEach(() => {
+ setValueAndTriggerValidation(validValue);
+ });
+
+ it('sets the field to be valid', () => {
+ expect(getFormData().fields.exampleField).toEqual({
+ state: true,
+ feedback: '',
+ });
+ });
+
+ it('sets the form to be valid', () => {
+ expect(getFormData().state).toBe(true);
+ });
+ });
+
+ describe('with invalid value', () => {
+ beforeEach(() => {
+ setValueAndTriggerValidation(invalidValue);
+ });
+
+ it('sets the field to be invalid', () => {
+ expect(getFormData().fields.exampleField).toEqual({
+ state: false,
+ feedback: expect.any(String),
+ });
+ expect(getFormData().fields.exampleField.feedback.length).toBeGreaterThan(0);
+ });
+
+ it('sets the form to be invalid', () => {
+ expect(getFormData().state).toBe(false);
+ });
+
+ it('sets focus on the first invalid input when the form is submitted', () => {
+ findForm().trigger('submit');
+ expect(findInput().element).toBe(document.activeElement);
+ });
+ });
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
index 31bdfc931ac..ab87d80b291 100644
--- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
+++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
@@ -5,7 +5,7 @@ import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_a
jest.mock('~/flash');
-describe('Grouped security reports app', () => {
+describe('Security reports app', () => {
let wrapper;
let mrTabsMock;
@@ -21,6 +21,8 @@ describe('Grouped security reports app', () => {
});
};
+ const anyParams = expect.any(Object);
+
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMrTabsMock = () => {
@@ -43,10 +45,12 @@ describe('Grouped security reports app', () => {
window.mrTabs = { tabShown: jest.fn() };
setupMockJobArtifact(reportType);
createComponent();
+ return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders the expected message', () => {
@@ -75,10 +79,12 @@ describe('Grouped security reports app', () => {
beforeEach(() => {
setupMockJobArtifact('foo');
createComponent();
+ return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders nothing', () => {
@@ -86,6 +92,42 @@ describe('Grouped security reports app', () => {
});
});
+ describe('security artifacts on last page of multi-page response', () => {
+ const numPages = 3;
+
+ beforeEach(() => {
+ jest
+ .spyOn(Api, 'pipelineJobs')
+ .mockImplementation(async (projectId, pipelineId, { page }) => {
+ const requestedPage = parseInt(page, 10);
+ if (requestedPage < numPages) {
+ return {
+ // Some jobs with no relevant artifacts
+ data: [{}, {}],
+ headers: { 'x-next-page': String(requestedPage + 1) },
+ };
+ } else if (requestedPage === numPages) {
+ return {
+ data: [{ artifacts: [{ file_type: SecurityReportsApp.reportTypes[0] }] }],
+ };
+ }
+
+ throw new Error('Test failed due to request of non-existent jobs page');
+ });
+
+ createComponent();
+ return wrapper.vm.$nextTick();
+ });
+
+ it('fetches all pages', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(numPages);
+ });
+
+ it('renders the expected message', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
+ });
+ });
+
describe('given an error from the API', () => {
let error;
@@ -93,10 +135,12 @@ describe('Grouped security reports app', () => {
error = new Error('an error');
jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
createComponent();
+ return wrapper.vm.$nextTick();
});
it('calls the pipelineJobs API correctly', () => {
- expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ expect(Api.pipelineJobs).toHaveBeenCalledTimes(1);
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId, anyParams);
});
it('renders nothing', () => {
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js
new file mode 100644
index 00000000000..a11f4e05913
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/actions_spec.js
@@ -0,0 +1,203 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+
+import createState from '~/vue_shared/security_reports/store/modules/sast/state';
+import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types';
+import * as actions from '~/vue_shared/security_reports/store/modules/sast/actions';
+import axios from '~/lib/utils/axios_utils';
+
+const diffEndpoint = 'diff-endpoint.json';
+const blobPath = 'blob-path.json';
+const reports = {
+ base: 'base',
+ head: 'head',
+ enrichData: 'enrichData',
+ diff: 'diff',
+};
+const error = 'Something went wrong';
+const vulnerabilityFeedbackPath = 'vulnerability-feedback-path';
+const rootState = { vulnerabilityFeedbackPath, blobPath };
+
+let state;
+
+describe('sast report actions', () => {
+ beforeEach(() => {
+ state = createState();
+ });
+
+ describe('setDiffEndpoint', () => {
+ it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => {
+ testAction(
+ actions.setDiffEndpoint,
+ diffEndpoint,
+ state,
+ [
+ {
+ type: types.SET_DIFF_ENDPOINT,
+ payload: diffEndpoint,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestDiff', () => {
+ it(`should commit ${types.REQUEST_DIFF}`, done => {
+ testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done);
+ });
+ });
+
+ describe('receiveDiffSuccess', () => {
+ it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => {
+ testAction(
+ actions.receiveDiffSuccess,
+ reports,
+ state,
+ [
+ {
+ type: types.RECEIVE_DIFF_SUCCESS,
+ payload: reports,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDiffError', () => {
+ it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => {
+ testAction(
+ actions.receiveDiffError,
+ error,
+ state,
+ [
+ {
+ type: types.RECEIVE_DIFF_ERROR,
+ payload: error,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchDiff', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.paths.diffEndpoint = diffEndpoint;
+ rootState.canReadVulnerabilityFeedback = true;
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('when diff and vulnerability feedback endpoints respond successfully', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(200, reports.diff)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(200, reports.enrichData);
+ });
+
+ it('should dispatch the `receiveDiffSuccess` action', done => {
+ const { diff, enrichData } = reports;
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [
+ { type: 'requestDiff' },
+ {
+ type: 'receiveDiffSuccess',
+ payload: {
+ diff,
+ enrichData,
+ },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => {
+ beforeEach(() => {
+ rootState.canReadVulnerabilityFeedback = false;
+ mock.onGet(diffEndpoint).replyOnce(200, reports.diff);
+ });
+
+ it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => {
+ const { diff } = reports;
+ const enrichData = [];
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [
+ { type: 'requestDiff' },
+ {
+ type: 'receiveDiffSuccess',
+ payload: {
+ diff,
+ enrichData,
+ },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('when the vulnerability feedback endpoint fails', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(200, reports.diff)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(404);
+ });
+
+ it('should dispatch the `receiveError` action', done => {
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
+ done,
+ );
+ });
+ });
+
+ describe('when the diff endpoint fails', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(404)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(200, reports.enrichData);
+ });
+
+ it('should dispatch the `receiveDiffError` action', done => {
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
+ done,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js
new file mode 100644
index 00000000000..fd611f38a34
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/store/modules/sast/mutations_spec.js
@@ -0,0 +1,84 @@
+import * as types from '~/vue_shared/security_reports/store/modules/sast/mutation_types';
+import createState from '~/vue_shared/security_reports/store/modules/sast/state';
+import mutations from '~/vue_shared/security_reports/store/modules/sast/mutations';
+
+const createIssue = ({ ...config }) => ({ changed: false, ...config });
+
+describe('sast module mutations', () => {
+ const path = 'path';
+ let state;
+
+ beforeEach(() => {
+ state = createState();
+ });
+
+ describe(types.SET_DIFF_ENDPOINT, () => {
+ it('should set the SAST diff endpoint', () => {
+ mutations[types.SET_DIFF_ENDPOINT](state, path);
+
+ expect(state.paths.diffEndpoint).toBe(path);
+ });
+ });
+
+ describe(types.REQUEST_DIFF, () => {
+ it('should set the `isLoading` status to `true`', () => {
+ mutations[types.REQUEST_DIFF](state);
+
+ expect(state.isLoading).toBe(true);
+ });
+ });
+
+ describe(types.RECEIVE_DIFF_SUCCESS, () => {
+ beforeEach(() => {
+ const reports = {
+ diff: {
+ added: [
+ createIssue({ cve: 'CVE-1' }),
+ createIssue({ cve: 'CVE-2' }),
+ createIssue({ cve: 'CVE-3' }),
+ ],
+ fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })],
+ existing: [createIssue({ cve: 'CVE-6' })],
+ base_report_out_of_date: true,
+ },
+ };
+ state.isLoading = true;
+ mutations[types.RECEIVE_DIFF_SUCCESS](state, reports);
+ });
+
+ it('should set the `isLoading` status to `false`', () => {
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('should set the `baseReportOutofDate` status to `false`', () => {
+ expect(state.baseReportOutofDate).toBe(true);
+ });
+
+ it('should have the relevant `new` issues', () => {
+ expect(state.newIssues).toHaveLength(3);
+ });
+
+ it('should have the relevant `resolved` issues', () => {
+ expect(state.resolvedIssues).toHaveLength(2);
+ });
+
+ it('should have the relevant `all` issues', () => {
+ expect(state.allIssues).toHaveLength(1);
+ });
+ });
+
+ describe(types.RECEIVE_DIFF_ERROR, () => {
+ beforeEach(() => {
+ state.isLoading = true;
+ mutations[types.RECEIVE_DIFF_ERROR](state);
+ });
+
+ it('should set the `isLoading` status to `false`', () => {
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('should set the `hasError` status to `true`', () => {
+ expect(state.hasError).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js
new file mode 100644
index 00000000000..bbcdfb5cd99
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/actions_spec.js
@@ -0,0 +1,203 @@
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+
+import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state';
+import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types';
+import * as actions from '~/vue_shared/security_reports/store/modules/secret_detection/actions';
+import axios from '~/lib/utils/axios_utils';
+
+const diffEndpoint = 'diff-endpoint.json';
+const blobPath = 'blob-path.json';
+const reports = {
+ base: 'base',
+ head: 'head',
+ enrichData: 'enrichData',
+ diff: 'diff',
+};
+const error = 'Something went wrong';
+const vulnerabilityFeedbackPath = 'vulnerability-feedback-path';
+const rootState = { vulnerabilityFeedbackPath, blobPath };
+
+let state;
+
+describe('secret detection report actions', () => {
+ beforeEach(() => {
+ state = createState();
+ });
+
+ describe('setDiffEndpoint', () => {
+ it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, done => {
+ testAction(
+ actions.setDiffEndpoint,
+ diffEndpoint,
+ state,
+ [
+ {
+ type: types.SET_DIFF_ENDPOINT,
+ payload: diffEndpoint,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestDiff', () => {
+ it(`should commit ${types.REQUEST_DIFF}`, done => {
+ testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done);
+ });
+ });
+
+ describe('receiveDiffSuccess', () => {
+ it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, done => {
+ testAction(
+ actions.receiveDiffSuccess,
+ reports,
+ state,
+ [
+ {
+ type: types.RECEIVE_DIFF_SUCCESS,
+ payload: reports,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveDiffError', () => {
+ it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, done => {
+ testAction(
+ actions.receiveDiffError,
+ error,
+ state,
+ [
+ {
+ type: types.RECEIVE_DIFF_ERROR,
+ payload: error,
+ },
+ ],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('fetchDiff', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.paths.diffEndpoint = diffEndpoint;
+ rootState.canReadVulnerabilityFeedback = true;
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('when diff and vulnerability feedback endpoints respond successfully', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(200, reports.diff)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(200, reports.enrichData);
+ });
+
+ it('should dispatch the `receiveDiffSuccess` action', done => {
+ const { diff, enrichData } = reports;
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [
+ { type: 'requestDiff' },
+ {
+ type: 'receiveDiffSuccess',
+ payload: {
+ diff,
+ enrichData,
+ },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('when diff endpoint responds successfully and fetching vulnerability feedback is not authorized', () => {
+ beforeEach(() => {
+ rootState.canReadVulnerabilityFeedback = false;
+ mock.onGet(diffEndpoint).replyOnce(200, reports.diff);
+ });
+
+ it('should dispatch the `receiveDiffSuccess` action with empty enrich data', done => {
+ const { diff } = reports;
+ const enrichData = [];
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [
+ { type: 'requestDiff' },
+ {
+ type: 'receiveDiffSuccess',
+ payload: {
+ diff,
+ enrichData,
+ },
+ },
+ ],
+ done,
+ );
+ });
+ });
+
+ describe('when the vulnerability feedback endpoint fails', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(200, reports.diff)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(404);
+ });
+
+ it('should dispatch the `receiveDiffError` action', done => {
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
+ done,
+ );
+ });
+ });
+
+ describe('when the diff endpoint fails', () => {
+ beforeEach(() => {
+ mock
+ .onGet(diffEndpoint)
+ .replyOnce(404)
+ .onGet(vulnerabilityFeedbackPath)
+ .replyOnce(200, reports.enrichData);
+ });
+
+ it('should dispatch the `receiveDiffError` action', done => {
+ testAction(
+ actions.fetchDiff,
+ {},
+ { ...rootState, ...state },
+ [],
+ [{ type: 'requestDiff' }, { type: 'receiveDiffError' }],
+ done,
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js
new file mode 100644
index 00000000000..13fcc0f47a3
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/store/modules/secret_detection/mutations_spec.js
@@ -0,0 +1,84 @@
+import * as types from '~/vue_shared/security_reports/store/modules/secret_detection/mutation_types';
+import createState from '~/vue_shared/security_reports/store/modules/secret_detection/state';
+import mutations from '~/vue_shared/security_reports/store/modules/secret_detection/mutations';
+
+const createIssue = ({ ...config }) => ({ changed: false, ...config });
+
+describe('secret detection module mutations', () => {
+ const path = 'path';
+ let state;
+
+ beforeEach(() => {
+ state = createState();
+ });
+
+ describe(types.SET_DIFF_ENDPOINT, () => {
+ it('should set the secret detection diff endpoint', () => {
+ mutations[types.SET_DIFF_ENDPOINT](state, path);
+
+ expect(state.paths.diffEndpoint).toBe(path);
+ });
+ });
+
+ describe(types.REQUEST_DIFF, () => {
+ it('should set the `isLoading` status to `true`', () => {
+ mutations[types.REQUEST_DIFF](state);
+
+ expect(state.isLoading).toBe(true);
+ });
+ });
+
+ describe(types.RECEIVE_DIFF_SUCCESS, () => {
+ beforeEach(() => {
+ const reports = {
+ diff: {
+ added: [
+ createIssue({ cve: 'CVE-1' }),
+ createIssue({ cve: 'CVE-2' }),
+ createIssue({ cve: 'CVE-3' }),
+ ],
+ fixed: [createIssue({ cve: 'CVE-4' }), createIssue({ cve: 'CVE-5' })],
+ existing: [createIssue({ cve: 'CVE-6' })],
+ base_report_out_of_date: true,
+ },
+ };
+ state.isLoading = true;
+ mutations[types.RECEIVE_DIFF_SUCCESS](state, reports);
+ });
+
+ it('should set the `isLoading` status to `false`', () => {
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('should set the `baseReportOutofDate` status to `true`', () => {
+ expect(state.baseReportOutofDate).toBe(true);
+ });
+
+ it('should have the relevant `new` issues', () => {
+ expect(state.newIssues).toHaveLength(3);
+ });
+
+ it('should have the relevant `resolved` issues', () => {
+ expect(state.resolvedIssues).toHaveLength(2);
+ });
+
+ it('should have the relevant `all` issues', () => {
+ expect(state.allIssues).toHaveLength(1);
+ });
+ });
+
+ describe(types.RECEIVE_DIFF_ERROR, () => {
+ beforeEach(() => {
+ state.isLoading = true;
+ mutations[types.RECEIVE_DIFF_ERROR](state);
+ });
+
+ it('should set the `isLoading` status to `false`', () => {
+ expect(state.isLoading).toBe(false);
+ });
+
+ it('should set the `hasError` status to `true`', () => {
+ expect(state.hasError).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vuex_shared/modules/members/actions_spec.js b/spec/frontend/vuex_shared/modules/members/actions_spec.js
index 833bd4cc175..c7048a9c421 100644
--- a/spec/frontend/vuex_shared/modules/members/actions_spec.js
+++ b/spec/frontend/vuex_shared/modules/members/actions_spec.js
@@ -3,79 +3,121 @@ import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { members, group } from 'jest/vue_shared/components/members/mock_data';
import testAction from 'helpers/vuex_action_helper';
+import { useFakeDate } from 'helpers/fake_date';
import httpStatusCodes from '~/lib/utils/http_status';
import * as types from '~/vuex_shared/modules/members/mutation_types';
import {
updateMemberRole,
showRemoveGroupLinkModal,
hideRemoveGroupLinkModal,
+ updateMemberExpiration,
} from '~/vuex_shared/modules/members/actions';
describe('Vuex members actions', () => {
- let mock;
+ describe('update member actions', () => {
+ let mock;
- beforeEach(() => {
- mock = new MockAdapter(axios);
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- describe('updateMemberRole', () => {
- const memberId = members[0].id;
- const accessLevel = { integerValue: 30, stringValue: 'Developer' };
-
- const payload = {
- memberId,
- accessLevel,
- };
const state = {
members,
memberPath: '/groups/foo-bar/-/group_members/:id',
requestFormatter: noop,
- removeGroupLinkModalVisible: false,
- groupLinkToRemove: null,
};
- describe('successful request', () => {
- it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => {
- let requestPath;
- mock.onPut().replyOnce(config => {
- requestPath = config.url;
- return [httpStatusCodes.OK, {}];
- });
-
- await testAction(updateMemberRole, payload, state, [
- {
- type: types.RECEIVE_MEMBER_ROLE_SUCCESS,
- payload,
- },
- ]);
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ });
- expect(requestPath).toBe('/groups/foo-bar/-/group_members/238');
- });
+ afterEach(() => {
+ mock.restore();
});
- describe('unsuccessful request', () => {
- beforeEach(() => {
- mock.onPut().replyOnce(httpStatusCodes.BAD_REQUEST, { message: 'Bad request' });
- });
+ describe('updateMemberRole', () => {
+ const memberId = members[0].id;
+ const accessLevel = { integerValue: 30, stringValue: 'Developer' };
+
+ const payload = {
+ memberId,
+ accessLevel,
+ };
+
+ describe('successful request', () => {
+ it(`commits ${types.RECEIVE_MEMBER_ROLE_SUCCESS} mutation`, async () => {
+ mock.onPut().replyOnce(httpStatusCodes.OK);
- it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation`, async () => {
- try {
await testAction(updateMemberRole, payload, state, [
{
type: types.RECEIVE_MEMBER_ROLE_SUCCESS,
+ payload,
},
]);
- } catch {
- // Do nothing
- }
+
+ expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238');
+ });
+ });
+
+ describe('unsuccessful request', () => {
+ it(`commits ${types.RECEIVE_MEMBER_ROLE_ERROR} mutation and throws error`, async () => {
+ mock.onPut().networkError();
+
+ await expect(
+ testAction(updateMemberRole, payload, state, [
+ {
+ type: types.RECEIVE_MEMBER_ROLE_ERROR,
+ },
+ ]),
+ ).rejects.toThrowError(new Error('Network Error'));
+ });
+ });
+ });
+
+ describe('updateMemberExpiration', () => {
+ useFakeDate(2020, 2, 15, 3);
+
+ const memberId = members[0].id;
+ const expiresAt = '2020-3-17';
+
+ describe('successful request', () => {
+ describe('changing expiration date', () => {
+ it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => {
+ mock.onPut().replyOnce(httpStatusCodes.OK);
+
+ await testAction(updateMemberExpiration, { memberId, expiresAt }, state, [
+ {
+ type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS,
+ payload: { memberId, expiresAt: '2020-03-17T00:00:00Z' },
+ },
+ ]);
+
+ expect(mock.history.put[0].url).toBe('/groups/foo-bar/-/group_members/238');
+ });
+ });
+
+ describe('removing the expiration date', () => {
+ it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_SUCCESS} mutation`, async () => {
+ mock.onPut().replyOnce(httpStatusCodes.OK);
+
+ await testAction(updateMemberExpiration, { memberId, expiresAt: null }, state, [
+ {
+ type: types.RECEIVE_MEMBER_EXPIRATION_SUCCESS,
+ payload: { memberId, expiresAt: null },
+ },
+ ]);
+ });
+ });
});
- it('throws error', async () => {
- await expect(testAction(updateMemberRole, payload, state)).rejects.toThrowError();
+ describe('unsuccessful request', () => {
+ it(`commits ${types.RECEIVE_MEMBER_EXPIRATION_ERROR} mutation and throws error`, async () => {
+ mock.onPut().networkError();
+
+ await expect(
+ testAction(updateMemberExpiration, { memberId, expiresAt }, state, [
+ {
+ type: types.RECEIVE_MEMBER_EXPIRATION_ERROR,
+ },
+ ]),
+ ).rejects.toThrowError(new Error('Network Error'));
+ });
});
});
});
diff --git a/spec/frontend/vuex_shared/modules/members/mutations_spec.js b/spec/frontend/vuex_shared/modules/members/mutations_spec.js
index 7338b19cfc9..710d43b8990 100644
--- a/spec/frontend/vuex_shared/modules/members/mutations_spec.js
+++ b/spec/frontend/vuex_shared/modules/members/mutations_spec.js
@@ -3,36 +3,63 @@ import mutations from '~/vuex_shared/modules/members/mutations';
import * as types from '~/vuex_shared/modules/members/mutation_types';
describe('Vuex members mutations', () => {
- describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => {
- it('updates member', () => {
- const state = {
+ describe('update member mutations', () => {
+ let state;
+
+ beforeEach(() => {
+ state = {
members,
+ showError: false,
+ errorMessage: '',
};
+ });
- const accessLevel = { integerValue: 30, stringValue: 'Developer' };
+ describe(types.RECEIVE_MEMBER_ROLE_SUCCESS, () => {
+ it('updates member', () => {
+ const accessLevel = { integerValue: 30, stringValue: 'Developer' };
- mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, {
- memberId: members[0].id,
- accessLevel,
+ mutations[types.RECEIVE_MEMBER_ROLE_SUCCESS](state, {
+ memberId: members[0].id,
+ accessLevel,
+ });
+
+ expect(state.members[0].accessLevel).toEqual(accessLevel);
});
+ });
+
+ describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => {
+ it('shows error message', () => {
+ mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state);
- expect(state.members[0].accessLevel).toEqual(accessLevel);
+ expect(state.showError).toBe(true);
+ expect(state.errorMessage).toBe(
+ "An error occurred while updating the member's role, please try again.",
+ );
+ });
});
- });
- describe(types.RECEIVE_MEMBER_ROLE_ERROR, () => {
- it('shows error message', () => {
- const state = {
- showError: false,
- errorMessage: '',
- };
+ describe(types.RECEIVE_MEMBER_EXPIRATION_SUCCESS, () => {
+ it('updates member', () => {
+ const expiresAt = '2020-03-17T00:00:00Z';
- mutations[types.RECEIVE_MEMBER_ROLE_ERROR](state);
+ mutations[types.RECEIVE_MEMBER_EXPIRATION_SUCCESS](state, {
+ memberId: members[0].id,
+ expiresAt,
+ });
- expect(state.showError).toBe(true);
- expect(state.errorMessage).toBe(
- "An error occurred while updating the member's role, please try again.",
- );
+ expect(state.members[0].expiresAt).toEqual(expiresAt);
+ });
+ });
+
+ describe(types.RECEIVE_MEMBER_EXPIRATION_ERROR, () => {
+ it('shows error message', () => {
+ mutations[types.RECEIVE_MEMBER_EXPIRATION_ERROR](state);
+
+ expect(state.showError).toBe(true);
+ expect(state.errorMessage).toBe(
+ "An error occurred while updating the member's expiration date, please try again.",
+ );
+ });
});
});
diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js
index 77c2ae19d1f..cba550b19db 100644
--- a/spec/frontend/whats_new/components/app_spec.js
+++ b/spec/frontend/whats_new/components/app_spec.js
@@ -1,8 +1,16 @@
import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex';
-import { GlDrawer } from '@gitlab/ui';
+import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import App from '~/whats_new/components/app.vue';
+import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
+
+const MOCK_DRAWER_BODY_HEIGHT = 42;
+
+jest.mock('~/whats_new/utils/get_drawer_body_height', () => ({
+ getDrawerBodyHeight: jest.fn().mockImplementation(() => MOCK_DRAWER_BODY_HEIGHT),
+}));
const localVue = createLocalVue();
localVue.use(Vuex);
@@ -20,11 +28,13 @@ describe('App', () => {
openDrawer: jest.fn(),
closeDrawer: jest.fn(),
fetchItems: jest.fn(),
+ setDrawerBodyHeight: jest.fn(),
};
state = {
open: true,
- features: null,
+ features: [],
+ drawerBodyHeight: null,
};
store = new Vuex.Store({
@@ -36,9 +46,15 @@ describe('App', () => {
localVue,
store,
propsData,
+ directives: {
+ GlResizeObserver: createMockDirective(),
+ },
});
};
+ const findInfiniteScroll = () => wrapper.find(GlInfiniteScroll);
+ const emitBottomReached = () => findInfiniteScroll().vm.$emit('bottomReached');
+
beforeEach(async () => {
document.body.dataset.page = 'test-page';
document.body.dataset.namespaceId = 'namespace-840';
@@ -47,6 +63,7 @@ describe('App', () => {
buildWrapper();
wrapper.vm.$store.state.features = [{ title: 'Whats New Drawer', url: 'www.url.com' }];
+ wrapper.vm.$store.state.drawerBodyHeight = MOCK_DRAWER_BODY_HEIGHT;
await wrapper.vm.$nextTick();
});
@@ -61,7 +78,7 @@ describe('App', () => {
expect(getDrawer().exists()).toBe(true);
});
- it('dispatches openDrawer when mounted', () => {
+ it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id',
@@ -90,7 +107,7 @@ describe('App', () => {
it('send an event when feature item is clicked', () => {
trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn);
- const link = wrapper.find('[data-testid="whats-new-title-link"]');
+ const link = wrapper.find('.whats-new-item-title-link');
triggerEvent(link.element);
expect(trackingSpy.mock.calls[1]).toMatchObject([
@@ -102,4 +119,46 @@ describe('App', () => {
},
]);
});
+
+ it('renders infinite scroll', () => {
+ const scroll = findInfiniteScroll();
+
+ expect(scroll.props()).toMatchObject({
+ fetchedItems: wrapper.vm.$store.state.features.length,
+ maxListHeight: MOCK_DRAWER_BODY_HEIGHT,
+ });
+ });
+
+ describe('bottomReached', () => {
+ beforeEach(() => {
+ actions.fetchItems.mockClear();
+ });
+
+ it('when nextPage exists it calls fetchItems', () => {
+ wrapper.vm.$store.state.pageInfo = { nextPage: 840 };
+ emitBottomReached();
+
+ expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), 840);
+ });
+
+ it('when nextPage does not exist it does not call fetchItems', () => {
+ wrapper.vm.$store.state.pageInfo = { nextPage: null };
+ emitBottomReached();
+
+ expect(actions.fetchItems).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls getDrawerBodyHeight and setDrawerBodyHeight when resize directive is triggered', () => {
+ const { value } = getBinding(getDrawer().element, 'gl-resize-observer');
+
+ value();
+
+ expect(getDrawerBodyHeight).toHaveBeenCalledWith(wrapper.find(GlDrawer).element);
+
+ expect(actions.setDrawerBodyHeight).toHaveBeenCalledWith(
+ expect.any(Object),
+ MOCK_DRAWER_BODY_HEIGHT,
+ );
+ });
});
diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js
index 95ab667d611..12722b1b3b1 100644
--- a/spec/frontend/whats_new/store/actions_spec.js
+++ b/spec/frontend/whats_new/store/actions_spec.js
@@ -30,7 +30,9 @@ describe('whats new actions', () => {
axiosMock = new MockAdapter(axios);
axiosMock
.onGet('/-/whats_new')
- .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }]);
+ .replyOnce(200, [{ title: 'Whats New Drawer', url: 'www.url.com' }], {
+ 'x-next-page': '2',
+ });
await waitForPromises();
});
@@ -39,10 +41,23 @@ describe('whats new actions', () => {
axiosMock.restore();
});
- it('should commit setFeatures', () => {
+ it('if already fetching, does not fetch', () => {
+ testAction(actions.fetchItems, {}, { fetching: true }, []);
+ });
+
+ it('should commit fetching, setFeatures and setPagination', () => {
testAction(actions.fetchItems, {}, {}, [
- { type: types.SET_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
+ { type: types.SET_FETCHING, payload: true },
+ { type: types.ADD_FEATURES, payload: [{ title: 'Whats New Drawer', url: 'www.url.com' }] },
+ { type: types.SET_PAGE_INFO, payload: { nextPage: 2 } },
+ { type: types.SET_FETCHING, payload: false },
]);
});
});
+
+ describe('setDrawerBodyHeight', () => {
+ testAction(actions.setDrawerBodyHeight, 42, {}, [
+ { type: types.SET_DRAWER_BODY_HEIGHT, payload: 42 },
+ ]);
+ });
});
diff --git a/spec/frontend/whats_new/store/mutations_spec.js b/spec/frontend/whats_new/store/mutations_spec.js
index feaa1dd2a3b..4967fb51d2b 100644
--- a/spec/frontend/whats_new/store/mutations_spec.js
+++ b/spec/frontend/whats_new/store/mutations_spec.js
@@ -23,10 +23,37 @@ describe('whats new mutations', () => {
});
});
- describe('setFeatures', () => {
- it('sets features to data', () => {
- mutations[types.SET_FEATURES](state, 'bells and whistles');
- expect(state.features).toBe('bells and whistles');
+ describe('addFeatures', () => {
+ it('adds features from data', () => {
+ mutations[types.ADD_FEATURES](state, ['bells and whistles']);
+ expect(state.features).toEqual(['bells and whistles']);
+ });
+
+ it('when there are already items, it adds items', () => {
+ state.features = ['shiny things'];
+ mutations[types.ADD_FEATURES](state, ['bells and whistles']);
+ expect(state.features).toEqual(['shiny things', 'bells and whistles']);
+ });
+ });
+
+ describe('setPageInfo', () => {
+ it('sets page info', () => {
+ mutations[types.SET_PAGE_INFO](state, { nextPage: 8 });
+ expect(state.pageInfo).toEqual({ nextPage: 8 });
+ });
+ });
+
+ describe('setFetching', () => {
+ it('sets fetching', () => {
+ mutations[types.SET_FETCHING](state, true);
+ expect(state.fetching).toBe(true);
+ });
+ });
+
+ describe('setDrawerBodyHeight', () => {
+ it('sets drawerBodyHeight', () => {
+ mutations[types.SET_DRAWER_BODY_HEIGHT](state, 840);
+ expect(state.drawerBodyHeight).toBe(840);
});
});
});
diff --git a/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js
new file mode 100644
index 00000000000..d096a3cbdc6
--- /dev/null
+++ b/spec/frontend/whats_new/utils/get_drawer_body_height_spec.js
@@ -0,0 +1,38 @@
+import { mount } from '@vue/test-utils';
+import { GlDrawer } from '@gitlab/ui';
+import { getDrawerBodyHeight } from '~/whats_new/utils/get_drawer_body_height';
+
+describe('~/whats_new/utils/get_drawer_body_height', () => {
+ let drawerWrapper;
+
+ beforeEach(() => {
+ drawerWrapper = mount(GlDrawer, {
+ propsData: { open: true },
+ });
+ });
+
+ afterEach(() => {
+ drawerWrapper.destroy();
+ });
+
+ const setClientHeight = (el, height) => {
+ Object.defineProperty(el, 'clientHeight', {
+ get() {
+ return height;
+ },
+ });
+ };
+ const setDrawerDimensions = ({ height, top, headerHeight }) => {
+ const drawer = drawerWrapper.element;
+
+ setClientHeight(drawer, height);
+ jest.spyOn(drawer, 'getBoundingClientRect').mockReturnValue({ top });
+ setClientHeight(drawer.querySelector('.gl-drawer-header'), headerHeight);
+ };
+
+ it('calculates height of drawer body', () => {
+ setDrawerDimensions({ height: 100, top: 5, headerHeight: 40 });
+
+ expect(getDrawerBodyHeight(drawerWrapper.element)).toBe(55);
+ });
+});
diff --git a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
index 6c120898f01..877cc78a111 100644
--- a/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
+++ b/spec/frontend_integration/ide/__snapshots__/ide_integration_spec.js.snap
@@ -9,12 +9,6 @@ exports[`WebIDE runs 1`] = `
class="ide-view flex-grow d-flex"
>
<div
- class="file-finder-overlay"
- style="display: none;"
- >
- (jest: contents hidden)
- </div>
- <div
class="gl-relative multi-file-commit-panel flex-column"
style="width: 340px;"
>
@@ -109,28 +103,12 @@ exports[`WebIDE runs 1`] = `
<h4>
Make and review changes in the browser with the Web IDE
</h4>
- <div
- class="gl-spinner-container"
- >
- <span
- 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/test_helpers/fixtures.js b/spec/frontend_integration/test_helpers/fixtures.js
index 5f9c0e8dcba..50fa4859dfa 100644
--- a/spec/frontend_integration/test_helpers/fixtures.js
+++ b/spec/frontend_integration/test_helpers/fixtures.js
@@ -1,4 +1,4 @@
-/* eslint-disable global-require */
+/* eslint-disable global-require, import/no-unresolved */
import { memoize } from 'lodash';
export const getProject = () => require('test_fixtures/api/projects/get.json');
diff --git a/spec/graphql/mutations/alert_management/http_integration/create_spec.rb b/spec/graphql/mutations/alert_management/http_integration/create_spec.rb
new file mode 100644
index 00000000000..9aa89761aaf
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/http_integration/create_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::HttpIntegration::Create do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:args) { { project_path: project.full_path, active: true, name: 'HTTP Integration' } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when HttpIntegrations::CreateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: ::AlertManagement::HttpIntegration.last!,
+ errors: []
+ )
+ end
+ end
+
+ context 'when HttpIntegrations::CreateService responds with an error' do
+ before do
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::CreateService)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An integration already exists'))
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['An integration 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/http_integration/destroy_spec.rb b/spec/graphql/mutations/alert_management/http_integration/destroy_spec.rb
new file mode 100644
index 00000000000..f74f9186743
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/http_integration/destroy_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::HttpIntegration::Destroy do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:integration) { create(:alert_management_http_integration, project: project) }
+ let(:args) { { id: GitlabSchema.id_from_object(integration) } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when HttpIntegrations::DestroyService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: []
+ )
+ end
+ end
+
+ context 'when HttpIntegrations::DestroyService responds with an error' do
+ before do
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::DestroyService)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'An error has occurred'))
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['An error has occurred']
+ )
+ 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/http_integration/reset_token_spec.rb b/spec/graphql/mutations/alert_management/http_integration/reset_token_spec.rb
new file mode 100644
index 00000000000..d3ffb2abb47
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/http_integration/reset_token_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::HttpIntegration::ResetToken do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
+ let(:args) { { id: GitlabSchema.id_from_object(integration) } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has sufficient access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when HttpIntegrations::UpdateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: []
+ )
+ end
+ end
+
+ context 'when HttpIntegrations::UpdateService responds with an error' do
+ before do
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::UpdateService)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'Token cannot be reset'))
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['Token cannot be reset']
+ )
+ 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/http_integration/update_spec.rb b/spec/graphql/mutations/alert_management/http_integration/update_spec.rb
new file mode 100644
index 00000000000..d6318e3161d
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/http_integration/update_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::HttpIntegration::Update do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
+ let(:args) { { id: GitlabSchema.id_from_object(integration), active: false, name: 'New Name' } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has sufficient access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when HttpIntegrations::UpdateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: []
+ )
+ end
+ end
+
+ context 'when HttpIntegrations::UpdateService responds with an error' do
+ before do
+ allow_any_instance_of(::AlertManagement::HttpIntegrations::UpdateService)
+ .to receive(:execute)
+ .and_return(ServiceResponse.error(payload: { integration: nil }, message: 'Failed to update'))
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['Failed to update']
+ )
+ 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/prometheus_integration/create_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
new file mode 100644
index 00000000000..02a5e2e74e2
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Create do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:args) { { project_path: project.full_path, active: true, api_url: 'http://prometheus.com/' } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when Prometheus Integration already exists' do
+ let_it_be(:existing_integration) { create(:prometheus_service, project: project) }
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['Multiple Prometheus integrations are not supported']
+ )
+ end
+ end
+
+ context 'when UpdateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: ::PrometheusService.last!,
+ errors: []
+ )
+ end
+
+ it 'creates a corresponding token' do
+ expect { resolve }.to change(::Alerting::ProjectAlertingSetting, :count).by(1)
+ end
+ end
+
+ context 'when UpdateService responds with an error' do
+ before do
+ allow_any_instance_of(::Projects::Operations::UpdateService)
+ .to receive(:execute)
+ .and_return({ status: :error, message: 'An error occurred' })
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: nil,
+ errors: ['An error occurred']
+ )
+ 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/prometheus_integration/reset_token_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
new file mode 100644
index 00000000000..45d92695e06
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::PrometheusIntegration::ResetToken do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:prometheus_service, project: project) }
+ let(:args) { { id: GitlabSchema.id_from_object(integration) } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has sufficient access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when ::Projects::Operations::UpdateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: []
+ )
+ end
+ end
+
+ context 'when ::Projects::Operations::UpdateService responds with an error' do
+ before do
+ allow_any_instance_of(::Projects::Operations::UpdateService)
+ .to receive(:execute)
+ .and_return({ status: :error, message: 'An error occurred' })
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: ['An error occurred']
+ )
+ 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/prometheus_integration/update_spec.rb b/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
new file mode 100644
index 00000000000..eab4474d827
--- /dev/null
+++ b/spec/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::AlertManagement::PrometheusIntegration::Update do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:prometheus_service, project: project) }
+ let(:args) { { id: GitlabSchema.id_from_object(integration), active: false, api_url: 'http://new-url.com' } }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+
+ describe '#resolve' do
+ subject(:resolve) { mutation_for(project, current_user).resolve(args) }
+
+ context 'user has sufficient access to project' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ context 'when ::Projects::Operations::UpdateService responds with success' do
+ it 'returns the integration with no errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: []
+ )
+ end
+ end
+
+ context 'when ::Projects::Operations::UpdateService responds with an error' do
+ before do
+ allow_any_instance_of(::Projects::Operations::UpdateService)
+ .to receive(:execute)
+ .and_return({ status: :error, message: 'An error occurred' })
+ end
+
+ it 'returns errors' do
+ expect(resolve).to eq(
+ integration: integration,
+ errors: ['An error occurred']
+ )
+ 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
index ab98088ebcd..08761ce64c2 100644
--- a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
+++ b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
@@ -37,8 +37,8 @@ RSpec.describe Mutations::AlertManagement::UpdateAlertStatus do
context 'error occurs when updating' do
it 'returns the alert with errors' do
# Stub an error on the alert
- allow_next_instance_of(Resolvers::AlertManagement::AlertResolver) do |resolver|
- allow(resolver).to receive(:resolve).and_return(alert)
+ allow_next_instance_of(::AlertManagement::AlertsFinder) do |finder|
+ allow(finder).to receive(:execute).and_return([alert])
end
allow(alert).to receive(:save).and_return(false)
diff --git a/spec/graphql/mutations/commits/create_spec.rb b/spec/graphql/mutations/commits/create_spec.rb
index fb1baafe7bd..82a5e3a62f5 100644
--- a/spec/graphql/mutations/commits/create_spec.rb
+++ b/spec/graphql/mutations/commits/create_spec.rb
@@ -5,8 +5,9 @@ require 'spec_helper'
RSpec.describe Mutations::Commits::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(:project) { create(:project, :public, :repository) }
+
let(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
@@ -18,9 +19,10 @@ RSpec.describe Mutations::Commits::Create do
specify { expect(described_class).to require_graphql_authorizations(:push_code) }
describe '#resolve' do
- subject { mutation.resolve(project_path: project.full_path, branch: branch, message: message, actions: actions) }
+ subject { mutation.resolve(project_path: project.full_path, branch: branch, start_branch: start_branch, message: message, actions: actions) }
let(:branch) { 'master' }
+ let(:start_branch) { nil }
let(:message) { 'Commit message' }
let(:actions) do
[
@@ -142,6 +144,29 @@ RSpec.describe Mutations::Commits::Create do
end
end
+ context 'when branch does not exist and a start branch is provided' do
+ let(:branch) { 'my-branch' }
+ let(:start_branch) { 'master' }
+ let(:actions) do
+ [
+ {
+ action: 'create',
+ file_path: 'ANOTHER_FILE.md',
+ content: 'Bye'
+ }
+ ]
+ end
+
+ it 'returns a new commit' do
+ expect(mutated_commit).to have_attributes(message: message, project: project)
+ expect(subject[:errors]).to be_empty
+
+ expect_to_contain_deltas([
+ a_hash_including(a_mode: '0', b_mode: '100644', new_file: true, new_path: 'ANOTHER_FILE.md')
+ ])
+ end
+ end
+
context 'when message is not set' do
let(:message) { nil }
diff --git a/spec/graphql/mutations/container_expiration_policies/update_spec.rb b/spec/graphql/mutations/container_expiration_policies/update_spec.rb
index 6aedaab3b53..9c6016e0af4 100644
--- a/spec/graphql/mutations/container_expiration_policies/update_spec.rb
+++ b/spec/graphql/mutations/container_expiration_policies/update_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe Mutations::ContainerExpirationPolicies::Update do
it_behaves_like 'not creating the container expiration policy'
- it "doesn't update the cadence" do
+ it 'doesn\'t update the cadence' do
expect { subject }
.not_to change { container_expiration_policy.reload.cadence }
end
@@ -47,6 +47,24 @@ RSpec.describe Mutations::ContainerExpirationPolicies::Update do
)
end
end
+
+ context 'with blank regex' do
+ let_it_be(:params) { { project_path: project.full_path, name_regex: '', enabled: true } }
+
+ it_behaves_like 'not creating the container expiration policy'
+
+ it "doesn't update the cadence" do
+ expect { subject }
+ .not_to change { container_expiration_policy.reload.cadence }
+ end
+
+ it 'returns an error' do
+ expect(subject).to eq(
+ container_expiration_policy: nil,
+ errors: ['Name regex can\'t be blank']
+ )
+ end
+ end
end
RSpec.shared_examples 'denying access to container expiration policy' do
diff --git a/spec/graphql/mutations/container_repositories/destroy_spec.rb b/spec/graphql/mutations/container_repositories/destroy_spec.rb
new file mode 100644
index 00000000000..3903196a511
--- /dev/null
+++ b/spec/graphql/mutations/container_repositories/destroy_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::ContainerRepositories::Destroy do
+ using RSpec::Parameterized::TableSyntax
+
+ let_it_be_with_reload(:container_repository) { create(:container_repository) }
+ let_it_be(:user) { create(:user) }
+
+ let(:project) { container_repository.project }
+ let(:id) { container_repository.to_global_id.to_s }
+
+ specify { expect(described_class).to require_graphql_authorizations(:destroy_container_image) }
+
+ describe '#resolve' do
+ subject do
+ described_class.new(object: nil, context: { current_user: user }, field: nil)
+ .resolve(id: id)
+ end
+
+ shared_examples 'destroying the container repository' do
+ it 'destroys the container repistory' do
+ expect(::Packages::CreateEventService)
+ .to receive(:new).with(nil, user, event_name: :delete_repository, scope: :container).and_call_original
+ expect(DeleteContainerRepositoryWorker)
+ .to receive(:perform_async).with(user.id, container_repository.id)
+
+ expect { subject }.to change { ::Packages::Event.count }.by(1)
+ expect(container_repository.reload.delete_scheduled?).to be true
+ end
+ end
+
+ shared_examples 'denying access to container respository' do
+ it 'raises an error' do
+ expect(DeleteContainerRepositoryWorker)
+ .not_to receive(:perform_async).with(user.id, container_repository.id)
+
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'with valid id' do
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'destroying the container repository'
+ :developer | 'destroying the container repository'
+ :reporter | 'denying access to container respository'
+ :guest | 'denying access to container respository'
+ :anonymous | 'denying access to container respository'
+ end
+
+ with_them do
+ before do
+ project.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+ end
+
+ context 'with invalid id' do
+ let(:id) { 'gid://gitlab/ContainerRepository/5555' }
+
+ it_behaves_like 'denying access to container respository'
+ end
+ end
+end
diff --git a/spec/graphql/mutations/issues/set_assignees_spec.rb b/spec/graphql/mutations/issues/set_assignees_spec.rb
index 77ba511b715..9a27c5acdac 100644
--- a/spec/graphql/mutations/issues/set_assignees_spec.rb
+++ b/spec/graphql/mutations/issues/set_assignees_spec.rb
@@ -3,6 +3,20 @@
require 'spec_helper'
RSpec.describe Mutations::Issues::SetAssignees do
+ context 'when the user does not have permissions' do
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ describe '#resolve' do
+ subject { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, assignee_usernames: [assignee.username]) }
+
+ it_behaves_like 'permission level for issue mutation is correctly verified'
+ end
+ end
+
it_behaves_like 'an assignable resource' do
let_it_be(:resource, reload: true) { create(:issue) }
end
diff --git a/spec/graphql/mutations/issues/set_confidential_spec.rb b/spec/graphql/mutations/issues/set_confidential_spec.rb
index 0b2fc0ecb93..c3269e5c0c0 100644
--- a/spec/graphql/mutations/issues/set_confidential_spec.rb
+++ b/spec/graphql/mutations/issues/set_confidential_spec.rb
@@ -17,9 +17,7 @@ RSpec.describe Mutations::Issues::SetConfidential do
subject { mutation.resolve(project_path: project.full_path, iid: issue.iid, confidential: confidential) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for issue mutation is correctly verified'
context 'when the user can update the issue' do
before do
diff --git a/spec/graphql/mutations/issues/set_due_date_spec.rb b/spec/graphql/mutations/issues/set_due_date_spec.rb
index a638971d966..9f8d0d6c405 100644
--- a/spec/graphql/mutations/issues/set_due_date_spec.rb
+++ b/spec/graphql/mutations/issues/set_due_date_spec.rb
@@ -16,9 +16,7 @@ RSpec.describe Mutations::Issues::SetDueDate do
subject { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, due_date: due_date) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for issue mutation is correctly verified'
context 'when the user can update the issue' do
before do
diff --git a/spec/graphql/mutations/issues/set_locked_spec.rb b/spec/graphql/mutations/issues/set_locked_spec.rb
index 10438226c17..1a0af0c6c63 100644
--- a/spec/graphql/mutations/issues/set_locked_spec.rb
+++ b/spec/graphql/mutations/issues/set_locked_spec.rb
@@ -15,9 +15,7 @@ RSpec.describe Mutations::Issues::SetLocked do
subject { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, locked: locked) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for issue mutation is correctly verified'
context 'when the user can update the issue' do
let(:mutated_issue) { subject[:issue] }
diff --git a/spec/graphql/mutations/issues/set_severity_spec.rb b/spec/graphql/mutations/issues/set_severity_spec.rb
index ed73d3b777e..7698118ae3e 100644
--- a/spec/graphql/mutations/issues/set_severity_spec.rb
+++ b/spec/graphql/mutations/issues/set_severity_spec.rb
@@ -15,11 +15,7 @@ RSpec.describe Mutations::Issues::SetSeverity do
subject(:resolve) { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, severity: severity) }
- context 'when the user cannot update the issue' do
- it 'raises an error' do
- expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
- end
+ it_behaves_like 'permission level for issue mutation is correctly verified'
context 'when the user can update the issue' do
before do
diff --git a/spec/graphql/mutations/issues/update_spec.rb b/spec/graphql/mutations/issues/update_spec.rb
index f9f4bdeb6fa..ce1eb874bcf 100644
--- a/spec/graphql/mutations/issues/update_spec.rb
+++ b/spec/graphql/mutations/issues/update_spec.rb
@@ -35,11 +35,7 @@ RSpec.describe Mutations::Issues::Update do
subject { mutation.resolve(mutation_params) }
- context 'when the user cannot access the issue' do
- it 'raises an error' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
- end
+ it_behaves_like 'permission level for issue mutation is correctly verified'
context 'when the user can update the issue' do
before do
diff --git a/spec/graphql/mutations/labels/create_spec.rb b/spec/graphql/mutations/labels/create_spec.rb
new file mode 100644
index 00000000000..8b284816d63
--- /dev/null
+++ b/spec/graphql/mutations/labels/create_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Labels::Create do
+ let_it_be(:user) { create(:user) }
+
+ let(:attributes) do
+ {
+ title: 'new title',
+ description: 'A new label'
+ }
+ end
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+ let(:mutated_label) { subject[:label] }
+
+ shared_examples 'create labels mutation' do
+ describe '#resolve' do
+ subject { mutation.resolve(attributes.merge(extra_params)) }
+
+ context 'when the user does not have permission to create a label' do
+ before do
+ parent.add_guest(user)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when the user can create a label' do
+ before do
+ parent.add_developer(user)
+ end
+
+ it 'creates label with correct values' do
+ expect(mutated_label).to have_attributes(attributes)
+ end
+ end
+ end
+ end
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_label) }
+
+ context 'when creating a project label' do
+ let_it_be(:parent) { create(:project) }
+ let(:extra_params) { { project_path: parent.full_path } }
+
+ it_behaves_like 'create labels mutation'
+ end
+
+ context 'when creating a group label' do
+ let_it_be(:parent) { create(:group) }
+ let(:extra_params) { { group_path: parent.full_path } }
+
+ it_behaves_like 'create labels mutation'
+ end
+
+ describe '#ready?' do
+ subject { mutation.ready?(attributes.merge(extra_params)) }
+
+ context 'when passing both project_path and group_path' do
+ let(:extra_params) { { project_path: 'foo', group_path: 'bar' } }
+
+ it 'raises an argument error' do
+ expect { subject }
+ .to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Exactly one of/)
+ end
+ end
+
+ context 'when passing only project_path or group_path' do
+ let(:extra_params) { { project_path: 'foo' } }
+
+ it 'does not raise an error' do
+ expect { subject }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/merge_requests/set_assignees_spec.rb b/spec/graphql/mutations/merge_requests/set_assignees_spec.rb
index 4ac40fc09c6..e2eab591341 100644
--- a/spec/graphql/mutations/merge_requests/set_assignees_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_assignees_spec.rb
@@ -3,6 +3,20 @@
require 'spec_helper'
RSpec.describe Mutations::MergeRequests::SetAssignees do
+ context 'when the user does not have permissions' do
+ let_it_be(:merge_request) { create(:merge_request) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:assignee) { create(:user) }
+
+ subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
+
+ describe '#resolve' do
+ subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, assignee_usernames: [assignee.username]) }
+
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
+ end
+ end
+
it_behaves_like 'an assignable resource' do
let_it_be(:resource, reload: true) { create(:merge_request) }
end
diff --git a/spec/graphql/mutations/merge_requests/set_labels_spec.rb b/spec/graphql/mutations/merge_requests/set_labels_spec.rb
index 62a7f650f84..1bb303cf99b 100644
--- a/spec/graphql/mutations/merge_requests/set_labels_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_labels_spec.rb
@@ -18,9 +18,7 @@ RSpec.describe Mutations::MergeRequests::SetLabels do
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, label_ids: label_ids) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
context 'when the user can update the merge request' do
before do
diff --git a/spec/graphql/mutations/merge_requests/set_locked_spec.rb b/spec/graphql/mutations/merge_requests/set_locked_spec.rb
index aca7df5445f..03c709e9bb3 100644
--- a/spec/graphql/mutations/merge_requests/set_locked_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_locked_spec.rb
@@ -16,9 +16,7 @@ RSpec.describe Mutations::MergeRequests::SetLocked do
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, locked: locked) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
context 'when the user can update the merge request' do
before do
diff --git a/spec/graphql/mutations/merge_requests/set_milestone_spec.rb b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
index ccb2d9bd132..4de857f43e3 100644
--- a/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_milestone_spec.rb
@@ -18,6 +18,8 @@ RSpec.describe Mutations::MergeRequests::SetMilestone do
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
+
context 'when the user can update the merge request' do
before do
project.add_developer(user)
diff --git a/spec/graphql/mutations/merge_requests/set_wip_spec.rb b/spec/graphql/mutations/merge_requests/set_wip_spec.rb
index b6cb49724fa..69f6a4328b8 100644
--- a/spec/graphql/mutations/merge_requests/set_wip_spec.rb
+++ b/spec/graphql/mutations/merge_requests/set_wip_spec.rb
@@ -16,9 +16,7 @@ RSpec.describe Mutations::MergeRequests::SetWip do
subject { mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, wip: wip) }
- it 'raises an error if the resource is not accessible to the user' do
- expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
- end
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
context 'when the user can update the merge request' do
before do
diff --git a/spec/graphql/mutations/merge_requests/update_spec.rb b/spec/graphql/mutations/merge_requests/update_spec.rb
index 4a1fdf6e74b..8acd2562ea8 100644
--- a/spec/graphql/mutations/merge_requests/update_spec.rb
+++ b/spec/graphql/mutations/merge_requests/update_spec.rb
@@ -18,9 +18,7 @@ RSpec.describe Mutations::MergeRequests::Update do
mutation.resolve(project_path: merge_request.project.full_path, iid: merge_request.iid, **attributes)
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
+ it_behaves_like 'permission level for merge request mutation is correctly verified'
context 'when the user can update the merge request' do
before do
diff --git a/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb b/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb
new file mode 100644
index 00000000000..8c22e1a1cb6
--- /dev/null
+++ b/spec/graphql/mutations/notes/reposition_image_diff_note_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Notes::RepositionImageDiffNote do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ subject do
+ mutation.resolve({ note: note, position: new_position })
+ end
+
+ let_it_be(:noteable) { create(:merge_request) }
+ let_it_be(:project) { noteable.project }
+ let(:note) { create(:image_diff_note_on_merge_request, noteable: noteable, project: project) }
+
+ let(:mutation) do
+ described_class.new(object: nil, context: { current_user: user }, field: nil)
+ end
+
+ let(:new_position) do
+ { x: 10, y: 11, width: 12, height: 13 }
+ end
+
+ context 'when the user does not have permission' do
+ let(:user) { nil }
+
+ it 'raises an error if the resource is not accessible to the user' do
+ expect { subject }.to raise_error(
+ Gitlab::Graphql::Errors::ResourceNotAvailable,
+ "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+ )
+ end
+ end
+
+ context 'when the user has permission' do
+ let(:user) { project.creator }
+ let(:mutated_note) { subject[:note] }
+ let(:errors) { subject[:errors] }
+
+ it 'mutates the note', :aggregate_failures do
+ expect { subject }.to change { note.reset.position.to_h }.to(include(new_position))
+
+ expect(mutated_note).to eq(note)
+ expect(errors).to be_empty
+ end
+
+ context 'when the note is a DiffNote, but not on an image' do
+ let(:note) { create(:diff_note_on_merge_request, noteable: noteable, project: project) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(
+ Gitlab::Graphql::Errors::ResourceNotAvailable,
+ 'Resource is not an ImageDiffNote'
+ )
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/releases/create_spec.rb b/spec/graphql/mutations/releases/create_spec.rb
new file mode 100644
index 00000000000..d6305691dac
--- /dev/null
+++ b/spec/graphql/mutations/releases/create_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Releases::Create do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:milestone_12_3) { create(:milestone, project: project, title: '12.3') }
+ let_it_be(:milestone_12_4) { create(:milestone, project: project, title: '12.4') }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ let(:tag) { 'v1.1.0'}
+ let(:ref) { 'master'}
+ let(:name) { 'Version 1.0'}
+ let(:description) { 'The first release :rocket:' }
+ let(:released_at) { Time.parse('2018-12-10') }
+ let(:milestones) { [milestone_12_3.title, milestone_12_4.title] }
+ let(:assets) do
+ {
+ links: [
+ {
+ name: 'An asset link',
+ url: 'https://gitlab.example.com/link',
+ filepath: '/permanent/link',
+ link_type: 'other'
+ }
+ ]
+ }
+ end
+
+ let(:mutation_arguments) do
+ {
+ project_path: project.full_path,
+ tag: tag,
+ ref: ref,
+ name: name,
+ description: description,
+ released_at: released_at,
+ milestones: milestones,
+ assets: assets
+ }
+ end
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ before do
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+ end
+
+ describe '#resolve' do
+ subject(:resolve) do
+ mutation.resolve(**mutation_arguments)
+ end
+
+ let(:new_release) { subject[:release] }
+
+ context 'when the current user has access to create releases' do
+ let(:current_user) { developer }
+
+ it 'returns no errors' do
+ expect(resolve).to include(errors: [])
+ end
+
+ it 'creates the release with the correct tag' do
+ expect(new_release.tag).to eq(tag)
+ end
+
+ it 'creates the release with the correct name' do
+ expect(new_release.name).to eq(name)
+ end
+
+ it 'creates the release with the correct description' do
+ expect(new_release.description).to eq(description)
+ end
+
+ it 'creates the release with the correct released_at' do
+ expect(new_release.released_at).to eq(released_at)
+ end
+
+ it 'creates the release with the correct created_at' do
+ expect(new_release.created_at).to eq(Time.current)
+ end
+
+ it 'creates the release with the correct milestone associations' do
+ expected_milestone_titles = [milestone_12_3.title, milestone_12_4.title]
+ actual_milestone_titles = new_release.milestones.map { |m| m.title }
+
+ # Right now the milestones are returned in a non-deterministic order.
+ # `match_array` should be updated to `eq` once
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/259012 is addressed.
+ expect(actual_milestone_titles).to match_array(expected_milestone_titles)
+ end
+
+ describe 'asset links' do
+ let(:expected_link) { assets[:links].first }
+ let(:new_link) { new_release.links.first }
+
+ it 'creates a single asset link' do
+ expect(new_release.links.size).to eq(1)
+ end
+
+ it 'creates the link with the correct name' do
+ expect(new_link.name).to eq(expected_link[:name])
+ end
+
+ it 'creates the link with the correct url' do
+ expect(new_link.url).to eq(expected_link[:url])
+ end
+
+ it 'creates the link with the correct link type' do
+ expect(new_link.link_type).to eq(expected_link[:link_type])
+ end
+
+ it 'creates the link with the correct direct filepath' do
+ expect(new_link.filepath).to eq(expected_link[:filepath])
+ end
+ end
+ end
+
+ context "when the current user doesn't have access to create releases" do
+ let(:current_user) { reporter }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/terraform/state/delete_spec.rb b/spec/graphql/mutations/terraform/state/delete_spec.rb
new file mode 100644
index 00000000000..313a85a4bac
--- /dev/null
+++ b/spec/graphql/mutations/terraform/state/delete_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Terraform::State::Delete do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:state) { create(:terraform_state) }
+
+ let(:mutation) do
+ described_class.new(
+ object: double,
+ context: { current_user: user },
+ field: double
+ )
+ end
+
+ it { expect(described_class.graphql_name).to eq('TerraformStateDelete') }
+ it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
+
+ describe '#resolve' do
+ let(:global_id) { state.to_global_id }
+
+ subject { mutation.resolve(id: global_id) }
+
+ context 'user does not have permission' do
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect { state.reload }.not_to raise_error
+ end
+ end
+
+ context 'user has permission' do
+ before do
+ state.project.add_maintainer(user)
+ end
+
+ it 'deletes the state', :aggregate_failures do
+ expect do
+ expect(subject).to eq(errors: [])
+ end.to change { ::Terraform::State.count }.by(-1)
+
+ expect { state.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+ end
+
+ context 'with invalid params' do
+ let(:global_id) { user.to_global_id }
+
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect { state.reload }.not_to raise_error
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/terraform/state/lock_spec.rb b/spec/graphql/mutations/terraform/state/lock_spec.rb
new file mode 100644
index 00000000000..c83563040fd
--- /dev/null
+++ b/spec/graphql/mutations/terraform/state/lock_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Terraform::State::Lock do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:state) { create(:terraform_state) }
+
+ let(:mutation) do
+ described_class.new(
+ object: double,
+ context: { current_user: user },
+ field: double
+ )
+ end
+
+ it { expect(described_class.graphql_name).to eq('TerraformStateLock') }
+ it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
+
+ describe '#resolve' do
+ let(:global_id) { state.to_global_id }
+
+ subject { mutation.resolve(id: global_id) }
+
+ context 'user does not have permission' do
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect(state.reload).not_to be_locked
+ end
+ end
+
+ context 'user has permission' do
+ before do
+ state.project.add_maintainer(user)
+ end
+
+ it 'locks the state', :aggregate_failures do
+ expect(subject).to eq(errors: [])
+
+ expect(state.reload).to be_locked
+ expect(state.locked_by_user).to eq(user)
+ expect(state.lock_xid).to be_present
+ expect(state.locked_at).to be_present
+ end
+
+ context 'state is already locked' do
+ let(:locked_by_user) { create(:user) }
+ let(:state) { create(:terraform_state, :locked, locked_by_user: locked_by_user) }
+
+ it 'does not modify the existing lock', :aggregate_failures do
+ expect(subject).to eq(errors: ['state is already locked'])
+
+ expect(state.reload).to be_locked
+ expect(state.locked_by_user).to eq(locked_by_user)
+ end
+ end
+ end
+
+ context 'with invalid params' do
+ let(:global_id) { user.to_global_id }
+
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect(state.reload).not_to be_locked
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/terraform/state/unlock_spec.rb b/spec/graphql/mutations/terraform/state/unlock_spec.rb
new file mode 100644
index 00000000000..4918a1c4abf
--- /dev/null
+++ b/spec/graphql/mutations/terraform/state/unlock_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Terraform::State::Unlock do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:state) { create(:terraform_state, :locked) }
+
+ let(:mutation) do
+ described_class.new(
+ object: double,
+ context: { current_user: user },
+ field: double
+ )
+ end
+
+ it { expect(described_class.graphql_name).to eq('TerraformStateUnlock') }
+ it { expect(described_class).to require_graphql_authorizations(:admin_terraform_state) }
+
+ describe '#resolve' do
+ let(:global_id) { state.to_global_id }
+
+ subject { mutation.resolve(id: global_id) }
+
+ context 'user does not have permission' do
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect(state.reload).to be_locked
+ end
+ end
+
+ context 'user has permission' do
+ before do
+ state.project.add_maintainer(user)
+ end
+
+ it 'unlocks the state', :aggregate_failures do
+ expect(subject).to eq(errors: [])
+ expect(state.reload).not_to be_locked
+ end
+
+ context 'state is already unlocked' do
+ let(:state) { create(:terraform_state) }
+
+ it 'does not modify the state' do
+ expect(subject).to eq(errors: [])
+ expect(state.reload).not_to be_locked
+ end
+ end
+ end
+
+ context 'with invalid params' do
+ let(:global_id) { user.to_global_id }
+
+ it 'raises an error', :aggregate_failures do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ expect(state.reload).to be_locked
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/todos/create_spec.rb b/spec/graphql/mutations/todos/create_spec.rb
new file mode 100644
index 00000000000..bbb033e2f33
--- /dev/null
+++ b/spec/graphql/mutations/todos/create_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Todos::Create do
+ include GraphqlHelpers
+ include DesignManagementTestHelpers
+
+ describe '#resolve' do
+ context 'when target does not support todos' do
+ it 'raises error' do
+ current_user = create(:user)
+ mutation = described_class.new(object: nil, context: { current_user: current_user }, field: nil)
+
+ target = create(:milestone)
+
+ expect { mutation.resolve(target_id: global_id_of(target)) }
+ .to raise_error(GraphQL::CoercionError)
+ end
+ end
+
+ context 'with issue as target' do
+ it_behaves_like 'create todo mutation' do
+ let_it_be(:target) { create(:issue) }
+ end
+ end
+
+ context 'with merge request as target' do
+ it_behaves_like 'create todo mutation' do
+ let_it_be(:target) { create(:merge_request) }
+ end
+ end
+
+ context 'with design as target' do
+ before do
+ enable_design_management
+ end
+
+ it_behaves_like 'create todo mutation' do
+ let_it_be(:target) { create(:design) }
+ end
+ end
+ end
+end
diff --git a/spec/graphql/mutations/todos/mark_all_done_spec.rb b/spec/graphql/mutations/todos/mark_all_done_spec.rb
index 2f167164050..f3b6bf52ef7 100644
--- a/spec/graphql/mutations/todos/mark_all_done_spec.rb
+++ b/spec/graphql/mutations/todos/mark_all_done_spec.rb
@@ -28,7 +28,7 @@ RSpec.describe Mutations::Todos::MarkAllDone do
expect(todo3.reload.state).to eq('done')
expect(other_user_todo.reload.state).to eq('pending')
- expect(updated_todo_ids).to contain_exactly(global_id_of(todo1), global_id_of(todo3))
+ expect(updated_todo_ids).to contain_exactly(todo1.id, todo3.id)
expect(todos).to contain_exactly(todo1, todo3)
end
diff --git a/spec/graphql/mutations/todos/restore_many_spec.rb b/spec/graphql/mutations/todos/restore_many_spec.rb
index 59995e33f2d..dc10355ef22 100644
--- a/spec/graphql/mutations/todos/restore_many_spec.rb
+++ b/spec/graphql/mutations/todos/restore_many_spec.rb
@@ -24,11 +24,11 @@ RSpec.describe Mutations::Todos::RestoreMany do
expect(todo2.reload.state).to eq('pending')
expect(other_user_todo.reload.state).to eq('done')
- todo_ids = result[:updated_ids]
- expect(todo_ids.size).to eq(1)
- expect(todo_ids.first).to eq(todo1.to_global_id.to_s)
-
- expect(result[:todos]).to contain_exactly(todo1)
+ expect(result).to match(
+ errors: be_empty,
+ updated_ids: contain_exactly(todo1.id),
+ todos: contain_exactly(todo1)
+ )
end
it 'handles a todo which is already pending as expected' do
@@ -36,8 +36,11 @@ RSpec.describe Mutations::Todos::RestoreMany do
expect_states_were_not_changed
- expect(result[:updated_ids]).to eq([])
- expect(result[:todos]).to be_empty
+ expect(result).to match(
+ errors: be_empty,
+ updated_ids: be_empty,
+ todos: be_empty
+ )
end
it 'ignores requests for todos which do not belong to the current user' do
@@ -61,7 +64,7 @@ RSpec.describe Mutations::Todos::RestoreMany do
expect(result[:updated_ids].size).to eq(2)
returned_todo_ids = result[:updated_ids]
- expect(returned_todo_ids).to contain_exactly(todo1.to_global_id.to_s, todo4.to_global_id.to_s)
+ expect(returned_todo_ids).to contain_exactly(todo1.id, todo4.id)
expect(result[:todos]).to contain_exactly(todo1, todo4)
expect(todo1.reload.state).to eq('pending')
diff --git a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
index c5637d43382..578d679ade4 100644
--- a/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
+++ b/spec/graphql/resolvers/admin/analytics/instance_statistics/measurements_resolver_spec.rb
@@ -14,7 +14,9 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
let_it_be(:project_measurement_new) { create(:instance_statistics_measurement, :project_count, recorded_at: 2.days.ago) }
let_it_be(:project_measurement_old) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago) }
- subject { resolve_measurements({ identifier: 'projects' }, { current_user: current_user }) }
+ let(:arguments) { { identifier: 'projects' } }
+
+ subject { resolve_measurements(arguments, { current_user: current_user }) }
context 'when requesting project count measurements' do
context 'as an admin user' do
@@ -40,6 +42,24 @@ RSpec.describe Resolvers::Admin::Analytics::InstanceStatistics::MeasurementsReso
expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
+
+ context 'when filtering by recorded_after and recorded_before' do
+ before do
+ arguments[:recorded_after] = 4.days.ago
+ arguments[:recorded_before] = 1.day.ago
+ end
+
+ it { is_expected.to match_array([project_measurement_new]) }
+
+ context 'when "incorrect" values are passed' do
+ before do
+ arguments[:recorded_after] = 1.day.ago
+ arguments[:recorded_before] = 4.days.ago
+ end
+
+ it { is_expected.to be_empty }
+ end
+ end
end
context 'when requesting pipeline counts by pipeline status' do
diff --git a/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb b/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb
new file mode 100644
index 00000000000..36e409e0677
--- /dev/null
+++ b/spec/graphql/resolvers/alert_management/integrations_resolver_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::AlertManagement::IntegrationsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:prometheus_integration) { create(:prometheus_service, project: project) }
+ let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) }
+ let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) }
+ let_it_be(:other_proj_integration) { create(:alert_management_http_integration) }
+
+ subject { sync(resolve_http_integrations) }
+
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::AlertManagement::IntegrationType.connection_type)
+ end
+
+ context 'user does not have permission' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'user has permission' do
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it { is_expected.to contain_exactly(active_http_integration, prometheus_integration) }
+ end
+
+ private
+
+ def resolve_http_integrations(args = {}, context = { current_user: current_user })
+ resolve(described_class, obj: project, ctx: context)
+ end
+end
diff --git a/spec/graphql/resolvers/base_resolver_spec.rb b/spec/graphql/resolvers/base_resolver_spec.rb
index 40dc2370052..e5b9fb57e42 100644
--- a/spec/graphql/resolvers/base_resolver_spec.rb
+++ b/spec/graphql/resolvers/base_resolver_spec.rb
@@ -7,10 +7,13 @@ RSpec.describe Resolvers::BaseResolver do
let(:resolver) do
Class.new(described_class) do
- def resolve(**args)
+ argument :test, ::GraphQL::INT_TYPE, required: false
+ type [::GraphQL::INT_TYPE], null: true
+
+ def resolve(test: 100)
process(object)
- [args, args]
+ [test, test]
end
def process(obj); end
@@ -19,17 +22,75 @@ RSpec.describe Resolvers::BaseResolver do
let(:last_resolver) do
Class.new(described_class) do
+ type [::GraphQL::INT_TYPE], null: true
+
def resolve(**args)
[1, 2]
end
end
end
+ describe '.singular_type' do
+ subject { resolver.singular_type }
+
+ context 'for a connection of scalars' do
+ let(:resolver) do
+ Class.new(described_class) do
+ type ::GraphQL::INT_TYPE.connection_type, null: true
+ end
+ end
+
+ it { is_expected.to eq(::GraphQL::INT_TYPE) }
+ end
+
+ context 'for a connection of objects' do
+ let(:object) do
+ Class.new(::Types::BaseObject) do
+ graphql_name 'Foo'
+ end
+ end
+
+ let(:resolver) do
+ conn = object.connection_type
+
+ Class.new(described_class) do
+ type conn, null: true
+ end
+ end
+
+ it { is_expected.to eq(object) }
+ end
+
+ context 'for a list type' do
+ let(:resolver) do
+ Class.new(described_class) do
+ type [::GraphQL::STRING_TYPE], null: true
+ end
+ end
+
+ it { is_expected.to eq(::GraphQL::STRING_TYPE) }
+ end
+
+ context 'for a scalar type' do
+ let(:resolver) do
+ Class.new(described_class) do
+ type ::GraphQL::BOOLEAN_TYPE, null: true
+ end
+ end
+
+ it { is_expected.to eq(::GraphQL::BOOLEAN_TYPE) }
+ end
+ end
+
describe '.single' do
it 'returns a subclass from the resolver' do
expect(resolver.single.superclass).to eq(resolver)
end
+ it 'has the correct (singular) type' do
+ expect(resolver.single.type).to eq(::GraphQL::INT_TYPE)
+ end
+
it 'returns the same subclass every time' do
expect(resolver.single.object_id).to eq(resolver.single.object_id)
end
@@ -37,15 +98,106 @@ RSpec.describe Resolvers::BaseResolver do
it 'returns a resolver that gives the first result from the original resolver' do
result = resolve(resolver.single, args: { test: 1 })
- expect(result).to eq(test: 1)
+ expect(result).to eq(1)
+ end
+ end
+
+ describe '.when_single' do
+ let(:resolver) do
+ Class.new(described_class) do
+ type [::GraphQL::INT_TYPE], null: true
+
+ when_single do
+ argument :foo, ::GraphQL::INT_TYPE, required: true
+ end
+
+ def resolve(foo: 1)
+ [foo * foo] # rubocop: disable Lint/BinaryOperatorWithIdenticalOperands
+ end
+ end
+ end
+
+ it 'does not apply the block to the resolver' do
+ expect(resolver.field_options).to include(
+ arguments: be_empty
+ )
+ result = resolve(resolver)
+
+ expect(result).to eq([1])
+ end
+
+ it 'applies the block to the single version of the resolver' do
+ expect(resolver.single.field_options).to include(
+ arguments: match('foo' => an_instance_of(::Types::BaseArgument))
+ )
+ result = resolve(resolver.single, args: { foo: 7 })
+
+ expect(result).to eq(49)
+ end
+
+ context 'multiple when_single blocks' do
+ let(:resolver) do
+ Class.new(described_class) do
+ type [::GraphQL::INT_TYPE], null: true
+
+ when_single do
+ argument :foo, ::GraphQL::INT_TYPE, required: true
+ end
+
+ when_single do
+ argument :bar, ::GraphQL::INT_TYPE, required: true
+ end
+
+ def resolve(foo: 1, bar: 2)
+ [foo * bar]
+ end
+ end
+ end
+
+ it 'applies both blocks to the single version of the resolver' do
+ expect(resolver.single.field_options).to include(
+ arguments: match('foo' => ::Types::BaseArgument, 'bar' => ::Types::BaseArgument)
+ )
+ result = resolve(resolver.single, args: { foo: 7, bar: 5 })
+
+ expect(result).to eq(35)
+ end
+ end
+
+ context 'inheritance' do
+ let(:subclass) do
+ Class.new(resolver) do
+ when_single do
+ argument :inc, ::GraphQL::INT_TYPE, required: true
+ end
+
+ def resolve(foo:, inc:)
+ super(foo: foo + inc)
+ end
+ end
+ end
+
+ it 'applies both blocks to the single version of the resolver' do
+ expect(resolver.single.field_options).to include(
+ arguments: match('foo' => ::Types::BaseArgument)
+ )
+ expect(subclass.single.field_options).to include(
+ arguments: match('foo' => ::Types::BaseArgument, 'inc' => ::Types::BaseArgument)
+ )
+ result = resolve(subclass.single, args: { foo: 7, inc: 1 })
+
+ expect(result).to eq(64)
+ end
end
end
context 'when the resolver returns early' do
let(:resolver) do
Class.new(described_class) do
+ type [::GraphQL::STRING_TYPE], null: true
+
def ready?(**args)
- [false, %w(early return)]
+ [false, %w[early return]]
end
def resolve(**args)
@@ -121,28 +273,4 @@ RSpec.describe Resolvers::BaseResolver do
end
end
end
-
- describe '#synchronized_object' do
- let(:object) { double(foo: :the_foo) }
-
- let(:resolver) do
- Class.new(described_class) do
- def resolve(**args)
- [synchronized_object.foo]
- end
- end
- end
-
- it 'handles raw objects' do
- expect(resolve(resolver, obj: object)).to contain_exactly(:the_foo)
- end
-
- it 'handles lazy objects' do
- delayed = BatchLoader::GraphQL.for(1).batch do |_, loader|
- loader.call(1, object)
- end
-
- expect(resolve(resolver, obj: delayed)).to contain_exactly(:the_foo)
- end
- end
end
diff --git a/spec/graphql/resolvers/ci/jobs_resolver_spec.rb b/spec/graphql/resolvers/ci/jobs_resolver_spec.rb
new file mode 100644
index 00000000000..a836c89bd61
--- /dev/null
+++ b/spec/graphql/resolvers/ci/jobs_resolver_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::JobsResolver do
+ include GraphqlHelpers
+
+ let_it_be(:pipeline) { create(:ci_pipeline) }
+
+ before_all do
+ create(:ci_build, name: 'Normal job', pipeline: pipeline)
+ create(:ci_build, :sast, name: 'DAST job', pipeline: pipeline)
+ create(:ci_build, :dast, name: 'SAST job', pipeline: pipeline)
+ create(:ci_build, :container_scanning, name: 'Container scanning job', pipeline: pipeline)
+ end
+
+ describe '#resolve' do
+ context 'when security_report_types is empty' do
+ it "returns all of the pipeline's jobs" do
+ jobs = resolve(described_class, obj: pipeline, args: {}, ctx: {})
+
+ job_names = jobs.map(&:name)
+ expect(job_names).to contain_exactly('Normal job', 'DAST job', 'SAST job', 'Container scanning job')
+ end
+ end
+
+ context 'when security_report_types is present' do
+ it "returns the pipeline's jobs with the given security report types" do
+ report_types = [
+ ::Types::Security::ReportTypeEnum.values['SAST'].value,
+ ::Types::Security::ReportTypeEnum.values['DAST'].value
+ ]
+ jobs = resolve(described_class, obj: pipeline, args: { security_report_types: report_types }, ctx: {})
+
+ job_names = jobs.map(&:name)
+ expect(job_names).to contain_exactly('DAST job', 'SAST job')
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/ci/runner_setup_resolver_spec.rb b/spec/graphql/resolvers/ci/runner_setup_resolver_spec.rb
new file mode 100644
index 00000000000..3d004290d9b
--- /dev/null
+++ b/spec/graphql/resolvers/ci/runner_setup_resolver_spec.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Ci::RunnerSetupResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let(:user) { create(:user) }
+
+ subject(:resolve_subject) { resolve(described_class, ctx: { current_user: user }, args: { platform: platform, architecture: 'amd64' }.merge(target_param)) }
+
+ context 'with container platforms' do
+ let(:platform) { 'docker' }
+ let(:project) { create(:project) }
+ let(:target_param) { { project_id: project.to_global_id } }
+
+ it 'returns install instructions' do
+ expect(resolve_subject[:install_instructions]).not_to eq(nil)
+ end
+
+ it 'does not return register instructions' do
+ expect(resolve_subject[:register_instructions]).to eq(nil)
+ end
+ end
+
+ context 'with regular platforms' do
+ let(:platform) { 'linux' }
+
+ context 'without target parameter' do
+ let(:target_param) { {} }
+
+ context 'when user is not admin' do
+ it 'returns access error' do
+ expect { resolve_subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when user is admin' do
+ before do
+ user.update!(admin: true)
+ end
+
+ it 'returns install and register instructions' do
+ expect(resolve_subject.keys).to contain_exactly(:install_instructions, :register_instructions)
+ expect(resolve_subject.values).not_to include(nil)
+ end
+ end
+ end
+
+ context 'with project target parameter' do
+ let(:project) { create(:project) }
+ let(:target_param) { { project_id: project.to_global_id } }
+
+ context 'when user has access to admin builds on project' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'returns install and register instructions' do
+ expect(resolve_subject.keys).to contain_exactly(:install_instructions, :register_instructions)
+ expect(resolve_subject.values).not_to include(nil)
+ end
+ end
+
+ context 'when user does not have access to admin builds on project' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'returns access error' do
+ expect { resolve_subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ context 'with group target parameter' do
+ let(:group) { create(:group) }
+ let(:target_param) { { group_id: group.to_global_id } }
+
+ context 'when user has access to admin builds on group' do
+ before do
+ group.add_owner(user)
+ end
+
+ it 'returns install and register instructions' do
+ expect(resolve_subject.keys).to contain_exactly(:install_instructions, :register_instructions)
+ expect(resolve_subject.values).not_to include(nil)
+ end
+ end
+
+ context 'when user does not have access to admin builds on group' do
+ before do
+ group.add_developer(user)
+ end
+
+ it 'returns access error' do
+ expect { resolve_subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb
new file mode 100644
index 00000000000..b6fe94a2312
--- /dev/null
+++ b/spec/graphql/resolvers/concerns/caching_array_resolver_spec.rb
@@ -0,0 +1,208 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::CachingArrayResolver do
+ include GraphqlHelpers
+
+ let_it_be(:non_admins) { create_list(:user, 4, admin: false) }
+ let(:query_context) { {} }
+ let(:max_page_size) { 10 }
+ let(:field) { double('Field', max_page_size: max_page_size) }
+ let(:schema) { double('Schema', default_max_page_size: 3) }
+
+ let_it_be(:caching_resolver) do
+ mod = described_class
+
+ Class.new(::Resolvers::BaseResolver) do
+ include mod
+
+ def query_input(is_admin:)
+ is_admin
+ end
+
+ def query_for(is_admin)
+ if is_admin.nil?
+ model_class.all
+ else
+ model_class.where(admin: is_admin)
+ end
+ end
+
+ def model_class
+ User # Happens to include FromUnion, and is cheap-ish to create
+ end
+ end
+ end
+
+ describe '#resolve' do
+ context 'there are more than MAX_UNION_SIZE queries' do
+ let_it_be(:max_union) { 3 }
+ let_it_be(:resolver) do
+ mod = described_class
+ max = max_union
+
+ Class.new(::Resolvers::BaseResolver) do
+ include mod
+
+ def query_input(username:)
+ username
+ end
+
+ def query_for(username)
+ if username.nil?
+ model_class.all
+ else
+ model_class.where(username: username)
+ end
+ end
+
+ def model_class
+ User # Happens to include FromUnion, and is cheap-ish to create
+ end
+
+ define_method :max_union_size do
+ max
+ end
+ end
+ end
+
+ it 'executes the queries in multiple batches' do
+ users = create_list(:user, (max_union * 2) + 1)
+ expect(User).to receive(:from_union).twice.and_call_original
+
+ results = users.in_groups_of(2, false).map do |users|
+ resolve(resolver, args: { username: users.map(&:username) }, field: field, schema: schema)
+ end
+
+ expect(results.flat_map(&method(:force))).to match_array(users)
+ end
+ end
+
+ context 'all queries return results' do
+ let_it_be(:admins) { create_list(:admin, 3) }
+
+ it 'batches the queries' do
+ expect do
+ [resolve_users(true), resolve_users(false)].each(&method(:force))
+ end.to issue_same_number_of_queries_as { force(resolve_users(nil)) }
+ end
+
+ it 'finds the correct values' do
+ found_admins = resolve_users(true)
+ found_others = resolve_users(false)
+ admins_again = resolve_users(true)
+ found_all = resolve_users(nil)
+
+ expect(force(found_admins)).to match_array(admins)
+ expect(force(found_others)).to match_array(non_admins)
+ expect(force(admins_again)).to match_array(admins)
+ expect(force(found_all)).to match_array(admins + non_admins)
+ end
+ end
+
+ it 'does not perform a union of a query with itself' do
+ expect(User).to receive(:where).once.and_call_original
+
+ [resolve_users(false), resolve_users(false)].each(&method(:force))
+ end
+
+ context 'one of the queries returns no results' do
+ it 'finds the correct values' do
+ found_admins = resolve_users(true)
+ found_others = resolve_users(false)
+ found_all = resolve_users(nil)
+
+ expect(force(found_admins)).to be_empty
+ expect(force(found_others)).to match_array(non_admins)
+ expect(force(found_all)).to match_array(non_admins)
+ end
+ end
+
+ context 'one of the queries has already been cached' do
+ before do
+ force(resolve_users(nil))
+ end
+
+ it 'avoids further queries' do
+ expect do
+ repeated_find = resolve_users(nil)
+
+ expect(force(repeated_find)).to match_array(non_admins)
+ end.not_to exceed_query_limit(0)
+ end
+ end
+
+ context 'the resolver overrides item_found' do
+ let_it_be(:admins) { create_list(:admin, 2) }
+ let(:query_context) do
+ {
+ found: { true => [], false => [], nil => [] }
+ }
+ end
+
+ let_it_be(:with_item_found) do
+ Class.new(caching_resolver) do
+ def item_found(key, item)
+ context[:found][key] << item
+ end
+ end
+ end
+
+ it 'receives item_found for each key the item mapped to' do
+ found_admins = resolve_users(true, with_item_found)
+ found_all = resolve_users(nil, with_item_found)
+
+ [found_admins, found_all].each(&method(:force))
+
+ expect(query_context[:found]).to match({
+ false => be_empty,
+ true => match_array(admins),
+ nil => match_array(admins + non_admins)
+ })
+ end
+ end
+
+ context 'the max_page_size is lower than the total result size' do
+ let(:max_page_size) { 2 }
+
+ it 'respects the max_page_size, on a per subset basis' do
+ found_all = resolve_users(nil)
+ found_others = resolve_users(false)
+
+ expect(force(found_all).size).to eq(2)
+ expect(force(found_others).size).to eq(2)
+ end
+ end
+
+ context 'the field does not declare max_page_size' do
+ let(:max_page_size) { nil }
+
+ it 'takes the page size from schema.default_max_page_size' do
+ found_all = resolve_users(nil)
+ found_others = resolve_users(false)
+
+ expect(force(found_all).size).to eq(schema.default_max_page_size)
+ expect(force(found_others).size).to eq(schema.default_max_page_size)
+ end
+ end
+
+ specify 'force . resolve === to_a . query_for . query_input' do
+ r = resolver_instance(caching_resolver)
+ args = { is_admin: false }
+
+ naive = r.query_for(r.query_input(**args)).to_a
+
+ expect(force(r.resolve(**args))).to eq(naive)
+ end
+ end
+
+ def resolve_users(is_admin, resolver = caching_resolver)
+ args = { is_admin: is_admin }
+ resolve(resolver, args: args, field: field, ctx: query_context, schema: schema)
+ end
+
+ def force(lazy)
+ ::Gitlab::Graphql::Lazy.force(lazy)
+ end
+end
diff --git a/spec/graphql/resolvers/container_repositories_resolver_spec.rb b/spec/graphql/resolvers/container_repositories_resolver_spec.rb
new file mode 100644
index 00000000000..b888d79626e
--- /dev/null
+++ b/spec/graphql/resolvers/container_repositories_resolver_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::ContainerRepositoriesResolver do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: group) }
+ let_it_be(:container_repositories) { create(:container_repository, project: project) }
+
+ let(:args) { {} }
+
+ describe '#resolve' do
+ let(:object) { project }
+
+ subject { resolve(described_class, ctx: { current_user: user }, args: args, obj: object) }
+
+ shared_examples 'returning container repositories' do
+ it { is_expected.to contain_exactly(container_repositories) }
+
+ context 'with a named search' do
+ let_it_be(:named_container_repository) { create(:container_repository, project: project, name: 'Foobar') }
+
+ let(:args) { { name: 'ooba' } }
+
+ it { is_expected.to contain_exactly(named_container_repository) }
+ end
+ end
+
+ context 'with authorized user' do
+ before do
+ group.add_user(user, :maintainer)
+ end
+
+ context 'when the object is a project' do
+ it_behaves_like 'returning container repositories'
+ end
+
+ context 'when the object is a group' do
+ let(:object) { group }
+
+ it_behaves_like 'returning container repositories'
+ end
+
+ context 'when the object is an invalid type' do
+ let(:object) { Object.new }
+
+ it { expect { subject }.to raise_exception('invalid subject_type') }
+ end
+ end
+
+ context 'with unauthorized user' do
+ it { is_expected.to be nil }
+ end
+ end
+end
diff --git a/spec/graphql/resolvers/design_management/design_resolver_spec.rb b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
index 02d7f94612c..e33eaedf167 100644
--- a/spec/graphql/resolvers/design_management/design_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/design_resolver_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe Resolvers::DesignManagement::DesignResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(::Types::DesignManagement::DesignType)
+ end
+
before do
enable_design_management
end
@@ -57,12 +61,21 @@ RSpec.describe Resolvers::DesignManagement::DesignResolver do
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 } }
+ let(:args) { { id: global_id_of(design_on_other_issue) } }
it 'returns nothing' do
expect(resolve_design).to be_nil
end
end
+
+ context 'the ID does not belong to a design at all' do
+ let(:args) { { id: global_id_of(issue) } }
+ let(:msg) { /does not represent an instance of DesignManagement::Design/ }
+
+ it 'complains meaningfully' do
+ expect { resolve_design }.to raise_error(msg)
+ end
+ end
end
context 'by filename' do
diff --git a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
index cfa37d34fd9..28e963c88a9 100644
--- a/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/designs_resolver_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe Resolvers::DesignManagement::DesignsResolver do
include GraphqlHelpers
include DesignManagementTestHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(::Types::DesignManagement::DesignType.connection_type)
+ end
+
before do
enable_design_management
end
@@ -65,8 +69,24 @@ RSpec.describe Resolvers::DesignManagement::DesignsResolver do
let(:second_version) { create(:design_version) }
let(:second_design) { create(:design, issue: issue, versions: [second_version]) }
+ context 'ids is provided but null' do
+ let(:args) { { ids: nil } }
+
+ it 'behaves as if unfiltered' do
+ expect(resolve_designs).to contain_exactly(first_design, second_design)
+ end
+ end
+
+ context 'ids is provided but empty' do
+ let(:args) { { ids: [] } }
+
+ it 'eliminates all values' do
+ expect(resolve_designs).to be_empty
+ end
+ end
+
context 'the ID is on the current issue' do
- let(:args) { { ids: [GitlabSchema.id_from_object(second_design).to_s] } }
+ let(:args) { { ids: [GitlabSchema.id_from_object(second_design)] } }
it 'resolves to just the relevant design' do
expect(resolve_designs).to contain_exactly(second_design)
@@ -77,7 +97,7 @@ RSpec.describe Resolvers::DesignManagement::DesignsResolver 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] } }
+ let(:args) { { ids: [GitlabSchema.id_from_object(third_design)] } }
it 'ignores it' do
expect(resolve_designs).to be_empty
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
index 8ad928e9854..403261fc22a 100644
--- a/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
+++ b/spec/graphql/resolvers/design_management/version_in_collection_resolver_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Resolvers::DesignManagement::VersionInCollectionResolver do
end
context 'we pass an id' do
- let(:params) { { id: global_id_of(first_version) } }
+ let(:params) { { version_id: global_id_of(first_version) } }
it { is_expected.to eq(first_version) }
end
@@ -44,13 +44,14 @@ RSpec.describe Resolvers::DesignManagement::VersionInCollectionResolver do
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)) } }
+ let(:params) { { sha: first_version.sha, version_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) } }
+ let(:params) { { version_id: global_id_of(project) } }
+ let(:appropriate_error) { ::GraphQL::CoercionError }
it 'raises an appropriate error' do
expect { result }.to raise_error(appropriate_error)
diff --git a/spec/graphql/resolvers/echo_resolver_spec.rb b/spec/graphql/resolvers/echo_resolver_spec.rb
index 2182ac221f6..4f48e5e0d7a 100644
--- a/spec/graphql/resolvers/echo_resolver_spec.rb
+++ b/spec/graphql/resolvers/echo_resolver_spec.rb
@@ -8,6 +8,10 @@ RSpec.describe Resolvers::EchoResolver do
let(:current_user) { create(:user) }
let(:text) { 'Message test' }
+ specify do
+ expect(described_class).to have_non_null_graphql_type(::GraphQL::STRING_TYPE)
+ end
+
describe '#resolve' do
it 'echoes text and username' do
expect(resolve_echo(text)).to eq %Q("#{current_user.username}" says: #{text})
diff --git a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
index 7e531910184..bf8d2139c82 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
let(:issue_details_service) { spy('ErrorTracking::IssueDetailsService') }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryDetailedErrorType)
+ end
+
before do
project.add_developer(current_user)
@@ -61,7 +65,9 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
context 'blank id' do
let(:args) { { id: '' } }
- it_behaves_like 'it resolves to nil'
+ it 'responds with an error' do
+ expect { resolve_error(args) }.to raise_error(::GraphQL::CoercionError)
+ end
end
end
diff --git a/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
index 02e0420be2a..20c2bdcd4e1 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_error_collection_resolver_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorCollectionResolver do
let(:list_issues_service) { spy('ErrorTracking::ListIssuesService') }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryErrorCollectionType)
+ end
+
before do
project.add_developer(current_user)
diff --git a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
index 554873a6e21..edca11f40d7 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_errors_resolver_spec.rb
@@ -14,6 +14,10 @@ RSpec.describe Resolvers::ErrorTracking::SentryErrorsResolver do
let(:issues) { nil }
let(:pagination) { nil }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::ErrorTracking::SentryErrorType.connection_type)
+ end
+
describe '#resolve' do
context 'insufficient user permission' do
let(:user) { create(:user) }
diff --git a/spec/graphql/resolvers/group_members_resolver_spec.rb b/spec/graphql/resolvers/group_members_resolver_spec.rb
index bbfea575492..bd0b4870062 100644
--- a/spec/graphql/resolvers/group_members_resolver_spec.rb
+++ b/spec/graphql/resolvers/group_members_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::GroupMembersResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::GroupMemberType.connection_type)
+ end
+
it_behaves_like 'querying members with a group' do
let_it_be(:resource_member) { create(:group_member, user: user_1, group: group_1) }
let_it_be(:resource) { group_1 }
diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb
index 3a6507f906c..43cbd4d2bdd 100644
--- a/spec/graphql/resolvers/issues_resolver_spec.rb
+++ b/spec/graphql/resolvers/issues_resolver_spec.rb
@@ -20,6 +20,10 @@ RSpec.describe Resolvers::IssuesResolver do
let_it_be(:label1) { create(:label, project: project) }
let_it_be(:label2) { create(:label, project: project) }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::IssueType.connection_type)
+ end
+
context "with a project" do
before do
project.add_developer(current_user)
diff --git a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
index ae3097c1d9e..deb5ff584cf 100644
--- a/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_request_pipelines_resolver_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe Resolvers::MergeRequestPipelinesResolver do
sha: merge_request.diff_head_sha
)
end
+
let_it_be(:other_project_pipeline) { create(:ci_pipeline, project: merge_request.source_project, ref: 'other-ref') }
let_it_be(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
diff --git a/spec/graphql/resolvers/merge_requests_resolver_spec.rb b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
index aecffc487aa..3a3393a185c 100644
--- a/spec/graphql/resolvers/merge_requests_resolver_spec.rb
+++ b/spec/graphql/resolvers/merge_requests_resolver_spec.rb
@@ -34,13 +34,13 @@ RSpec.describe Resolvers::MergeRequestsResolver do
context 'no arguments' do
it 'returns all merge requests' do
- result = resolve_mr(project, {})
+ result = resolve_mr(project)
expect(result).to contain_exactly(merge_request_1, merge_request_2, merge_request_3, merge_request_4, merge_request_5, merge_request_6, merge_request_with_milestone)
end
it 'returns only merge requests that the current user can see' do
- result = resolve_mr(project, {}, user: build(:user))
+ result = resolve_mr(project, user: build(:user))
expect(result).to be_empty
end
@@ -236,10 +236,10 @@ RSpec.describe Resolvers::MergeRequestsResolver do
end
def resolve_mr_single(project, iid)
- resolve_mr(project, { iids: iid }, resolver: described_class.single)
+ resolve_mr(project, resolver: described_class.single, iids: iid)
end
- def resolve_mr(project, args, resolver: described_class, user: current_user)
+ def resolve_mr(project, resolver: described_class, user: current_user, **args)
resolve(resolver, obj: project, args: args, ctx: { current_user: user })
end
end
diff --git a/spec/graphql/resolvers/metadata_resolver_spec.rb b/spec/graphql/resolvers/metadata_resolver_spec.rb
index 20556941de4..f8c01f9d531 100644
--- a/spec/graphql/resolvers/metadata_resolver_spec.rb
+++ b/spec/graphql/resolvers/metadata_resolver_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Resolvers::MetadataResolver do
describe '#resolve' do
it 'returns version and revision' do
- expect(resolve(described_class)).to eq(version: Gitlab::VERSION, revision: Gitlab.revision)
+ expect(resolve(described_class)).to have_attributes(version: Gitlab::VERSION, revision: Gitlab.revision)
end
end
end
diff --git a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
index a6a86c49373..1950c2ca067 100644
--- a/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
+++ b/spec/graphql/resolvers/project_pipeline_resolver_spec.rb
@@ -10,6 +10,10 @@ RSpec.describe Resolvers::ProjectPipelineResolver do
let_it_be(:other_pipeline) { create(:ci_pipeline) }
let(:current_user) { create(:user) }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(::Types::Ci::PipelineType)
+ end
+
def resolve_pipeline(project, args)
resolve(described_class, obj: project, args: args, ctx: { current_user: current_user })
end
diff --git a/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
index 0775c1c31d1..ad59cb6b95e 100644
--- a/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/jira_imports_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::Projects::JiraImportsResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::JiraImportType.connection_type)
+ end
+
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:project, reload: true) { create(:project, :public) }
diff --git a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb
index 840aea8b8c4..c375345250d 100644
--- a/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/jira_projects_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::Projects::JiraProjectsResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::Projects::Services::JiraProjectType.connection_type)
+ end
+
describe '#resolve' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
diff --git a/spec/graphql/resolvers/projects/services_resolver_spec.rb b/spec/graphql/resolvers/projects/services_resolver_spec.rb
index 8b6eff9e8b6..a1b631113b2 100644
--- a/spec/graphql/resolvers/projects/services_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/services_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::Projects::ServicesResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::Projects::ServiceType.connection_type)
+ end
+
describe '#resolve' do
let_it_be(:user) { create(:user) }
diff --git a/spec/graphql/resolvers/projects/snippets_resolver_spec.rb b/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
index b4a5eb8ddb0..6f7feff8fe5 100644
--- a/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects/snippets_resolver_spec.rb
@@ -56,12 +56,6 @@ RSpec.describe Resolvers::Projects::SnippetsResolver do
expect(snippets).to contain_exactly(project_snippet, other_project_snippet)
end
-
- it 'returns an error if the gid is invalid' do
- expect do
- resolve_snippets(args: { ids: 'foo' })
- end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
- end
end
context 'when no project is provided' do
diff --git a/spec/graphql/resolvers/projects_resolver_spec.rb b/spec/graphql/resolvers/projects_resolver_spec.rb
index 83a26062957..3de54c7e410 100644
--- a/spec/graphql/resolvers/projects_resolver_spec.rb
+++ b/spec/graphql/resolvers/projects_resolver_spec.rb
@@ -134,8 +134,8 @@ RSpec.describe Resolvers::ProjectsResolver do
is_expected.to eq([named_project3, named_project1, named_project2])
end
- it 'returns projects not in order of similarity to search if flag is off' do
- is_expected.not_to eq([named_project3, named_project1, named_project2])
+ it 'returns projects in any order if flag is off' do
+ is_expected.to match_array([named_project3, named_project1, named_project2])
end
end
end
diff --git a/spec/graphql/resolvers/release_resolver_spec.rb b/spec/graphql/resolvers/release_resolver_spec.rb
index 666d54fbc3c..04765fc68e9 100644
--- a/spec/graphql/resolvers/release_resolver_spec.rb
+++ b/spec/graphql/resolvers/release_resolver_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe Resolvers::ReleaseResolver do
let(:args) { {} }
it 'raises an error' do
- expect { resolve_release }.to raise_error(ArgumentError, "missing keyword: tag_name")
+ expect { resolve_release }.to raise_error(ArgumentError, "missing keyword: :tag_name")
end
end
end
diff --git a/spec/graphql/resolvers/releases_resolver_spec.rb b/spec/graphql/resolvers/releases_resolver_spec.rb
index ee8b33fc748..b9b90686aa7 100644
--- a/spec/graphql/resolvers/releases_resolver_spec.rb
+++ b/spec/graphql/resolvers/releases_resolver_spec.rb
@@ -5,12 +5,19 @@ require 'spec_helper'
RSpec.describe Resolvers::ReleasesResolver do
include GraphqlHelpers
+ let_it_be(:today) { Time.now }
+ let_it_be(:yesterday) { today - 1.day }
+ let_it_be(:tomorrow) { today + 1.day }
+
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(:release_v1) { create(:release, project: project, tag: 'v1.0.0', released_at: yesterday, created_at: tomorrow) }
+ let_it_be(:release_v2) { create(:release, project: project, tag: 'v2.0.0', released_at: today, created_at: yesterday) }
+ let_it_be(:release_v3) { create(:release, project: project, tag: 'v3.0.0', released_at: tomorrow, created_at: today) }
let_it_be(:developer) { create(:user) }
let_it_be(:public_user) { create(:user) }
+ let(:args) { { sort: :released_at_desc } }
+
before do
project.add_developer(developer)
end
@@ -28,7 +35,41 @@ RSpec.describe Resolvers::ReleasesResolver do
let(:current_user) { developer }
it 'returns all releases associated to the project' do
- expect(resolve_releases).to eq([release_v1, release_v2])
+ expect(resolve_releases).to eq([release_v3, release_v2, release_v1])
+ end
+
+ describe 'sorting behavior' do
+ context 'with sort: :released_at_desc' do
+ let(:args) { { sort: :released_at_desc } }
+
+ it 'returns the releases ordered by released_at in descending order' do
+ expect(resolve_releases).to eq([release_v3, release_v2, release_v1])
+ end
+ end
+
+ context 'with sort: :released_at_asc' do
+ let(:args) { { sort: :released_at_asc } }
+
+ it 'returns the releases ordered by released_at in ascending order' do
+ expect(resolve_releases).to eq([release_v1, release_v2, release_v3])
+ end
+ end
+
+ context 'with sort: :created_desc' do
+ let(:args) { { sort: :created_desc } }
+
+ it 'returns the releases ordered by created_at in descending order' do
+ expect(resolve_releases).to eq([release_v1, release_v3, release_v2])
+ end
+ end
+
+ context 'with sort: :created_asc' do
+ let(:args) { { sort: :created_asc } }
+
+ it 'returns the releases ordered by created_at in ascending order' do
+ expect(resolve_releases).to eq([release_v2, release_v3, release_v1])
+ end
+ end
end
end
end
@@ -37,6 +78,6 @@ RSpec.describe Resolvers::ReleasesResolver do
def resolve_releases
context = { current_user: current_user }
- resolve(described_class, obj: project, args: {}, ctx: context)
+ resolve(described_class, obj: project, args: args, ctx: context)
end
end
diff --git a/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb b/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb
index fdbd87c32be..16e69f662c0 100644
--- a/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb
+++ b/spec/graphql/resolvers/snippets/blobs_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::Snippets::BlobsResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::Snippets::BlobType.connection_type)
+ end
+
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:snippet) { create(:personal_snippet, :private, :repository, author: current_user) }
diff --git a/spec/graphql/resolvers/snippets_resolver_spec.rb b/spec/graphql/resolvers/snippets_resolver_spec.rb
index 180be8e8624..a58d9c5ac3a 100644
--- a/spec/graphql/resolvers/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/snippets_resolver_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe Resolvers::SnippetsResolver do
context 'when using filters' do
context 'by author id' do
it 'returns the snippets' do
- snippets = resolve_snippets(args: { author_id: current_user.to_global_id })
+ snippets = resolve_snippets(args: { author_id: global_id_of(current_user) })
expect(snippets).to contain_exactly(personal_snippet, project_snippet)
end
@@ -44,7 +44,7 @@ RSpec.describe Resolvers::SnippetsResolver do
it 'returns an error if the param id is invalid' do
expect do
resolve_snippets(args: { author_id: 'foo' })
- end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end.to raise_error(GraphQL::CoercionError)
end
end
@@ -65,7 +65,7 @@ RSpec.describe Resolvers::SnippetsResolver do
it 'returns an error if the param id is invalid' do
expect do
resolve_snippets(args: { project_id: 'foo' })
- end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end.to raise_error(GraphQL::CoercionError)
end
end
@@ -99,14 +99,14 @@ RSpec.describe Resolvers::SnippetsResolver do
expect(snippets).to contain_exactly(personal_snippet, project_snippet)
end
- it 'returns an error if the gid is invalid' do
+ it 'returns an error if the id cannot be coerced' do
args = {
ids: [personal_snippet.to_global_id, 'foo']
}
expect do
resolve_snippets(args: args)
- end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end.to raise_error(GraphQL::CoercionError, '"foo" is not a valid Global ID')
end
it 'returns an error if both project and author are provided' do
diff --git a/spec/graphql/resolvers/todo_resolver_spec.rb b/spec/graphql/resolvers/todo_resolver_spec.rb
index 83e3140b676..c764f389c16 100644
--- a/spec/graphql/resolvers/todo_resolver_spec.rb
+++ b/spec/graphql/resolvers/todo_resolver_spec.rb
@@ -5,6 +5,10 @@ require 'spec_helper'
RSpec.describe Resolvers::TodoResolver do
include GraphqlHelpers
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::TodoType.connection_type)
+ end
+
describe '#resolve' do
let_it_be(:current_user) { create(:user) }
let_it_be(:author1) { create(:user) }
diff --git a/spec/graphql/resolvers/tree_resolver_spec.rb b/spec/graphql/resolvers/tree_resolver_spec.rb
index 7818c25fe47..9eafd272771 100644
--- a/spec/graphql/resolvers/tree_resolver_spec.rb
+++ b/spec/graphql/resolvers/tree_resolver_spec.rb
@@ -7,6 +7,10 @@ RSpec.describe Resolvers::TreeResolver do
let(:repository) { create(:project, :repository).repository }
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::Tree::TreeType)
+ end
+
describe '#resolve' do
it 'resolves to a tree' do
result = resolve_repository({ ref: "master" })
diff --git a/spec/graphql/resolvers/users/group_count_resolver_spec.rb b/spec/graphql/resolvers/users/group_count_resolver_spec.rb
new file mode 100644
index 00000000000..47160a33646
--- /dev/null
+++ b/spec/graphql/resolvers/users/group_count_resolver_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Resolvers::Users::GroupCountResolver do
+ include GraphqlHelpers
+
+ describe '#resolve' do
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
+ let_it_be(:group1) { create(:group) }
+ let_it_be(:group2) { create(:group) }
+ let_it_be(:project) { create(:project, group: create(:group)) }
+ let_it_be(:group_member1) { create(:group_member, source: group1, user_id: user1.id, access_level: Gitlab::Access::OWNER) }
+ let_it_be(:project_member1) { create(:project_member, source: project, user_id: user1.id, access_level: Gitlab::Access::DEVELOPER) }
+ let_it_be(:group_member2) { create(:group_member, source: group2, user_id: user2.id, access_level: Gitlab::Access::DEVELOPER) }
+
+ it 'resolves group count for users' do
+ current_user = user1
+
+ result = batch_sync do
+ [user1, user2].map { |user| resolve_group_count(user, current_user) }
+ end
+
+ expect(result).to eq([2, nil])
+ end
+
+ context 'permissions' do
+ context 'when current_user is an admin', :enable_admin_mode do
+ let_it_be(:admin) { create(:admin) }
+
+ it do
+ result = batch_sync do
+ [user1, user2].map { |user| resolve_group_count(user, admin) }
+ end
+
+ expect(result).to eq([2, 1])
+ end
+ end
+
+ context 'when current_user does not have access to the requested resource' do
+ it do
+ result = batch_sync { resolve_group_count(user1, user2) }
+
+ expect(result).to be nil
+ end
+ end
+
+ context 'when current_user does not exist' do
+ it do
+ result = batch_sync { resolve_group_count(user1, nil) }
+
+ expect(result).to be nil
+ end
+ end
+ end
+ end
+
+ def resolve_group_count(user, current_user)
+ resolve(described_class, obj: user, ctx: { current_user: current_user })
+ end
+end
diff --git a/spec/graphql/resolvers/users/snippets_resolver_spec.rb b/spec/graphql/resolvers/users/snippets_resolver_spec.rb
index 497b6b11b46..9ccbebc59e6 100644
--- a/spec/graphql/resolvers/users/snippets_resolver_spec.rb
+++ b/spec/graphql/resolvers/users/snippets_resolver_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe Resolvers::Users::SnippetsResolver do
expect do
resolve_snippets(args: args)
- end.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
+ end.to raise_error(GraphQL::CoercionError)
end
end
end
diff --git a/spec/graphql/resolvers/users_resolver_spec.rb b/spec/graphql/resolvers/users_resolver_spec.rb
index e3d595e0790..1aa24055a89 100644
--- a/spec/graphql/resolvers/users_resolver_spec.rb
+++ b/spec/graphql/resolvers/users_resolver_spec.rb
@@ -5,8 +5,12 @@ require 'spec_helper'
RSpec.describe Resolvers::UsersResolver do
include GraphqlHelpers
- let_it_be(:user1) { create(:user) }
- let_it_be(:user2) { create(:user) }
+ let_it_be(:user1) { create(:user, name: "SomePerson") }
+ let_it_be(:user2) { create(:user, username: "someone123784") }
+
+ specify do
+ expect(described_class).to have_nullable_graphql_type(Types::UserType.connection_type)
+ end
describe '#resolve' do
it 'raises an error when read_users_list is not authorized' do
@@ -43,6 +47,14 @@ RSpec.describe Resolvers::UsersResolver do
).to contain_exactly(user1, user2)
end
end
+
+ context 'when a search term is passed' do
+ it 'returns all users who match', :aggregate_failures do
+ expect(resolve_users(search: "some")).to contain_exactly(user1, user2)
+ expect(resolve_users(search: "123784")).to contain_exactly(user2)
+ expect(resolve_users(search: "someperson")).to contain_exactly(user1)
+ end
+ end
end
def resolve_users(args = {})
diff --git a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb b/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
index 625fb17bbf8..8a7408224a2 100644
--- a/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
+++ b/spec/graphql/types/admin/analytics/instance_statistics/measurement_identifier_enum_spec.rb
@@ -6,7 +6,10 @@ RSpec.describe GitlabSchema.types['MeasurementIdentifier'] do
specify { expect(described_class.graphql_name).to eq('MeasurementIdentifier') }
it 'exposes all the existing identifier values' do
- identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.map(&:upcase)
+ ee_only_identifiers = %w[billable_users]
+ identifiers = Analytics::InstanceStatistics::Measurement.identifiers.keys.reject do |x|
+ ee_only_identifiers.include?(x)
+ end.map(&:upcase)
expect(described_class.values.keys).to match_array(identifiers)
end
diff --git a/spec/graphql/types/alert_management/http_integration_type_spec.rb b/spec/graphql/types/alert_management/http_integration_type_spec.rb
new file mode 100644
index 00000000000..a4b64e2e37f
--- /dev/null
+++ b/spec/graphql/types/alert_management/http_integration_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AlertManagementHttpIntegration'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementHttpIntegration') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:admin_operations) }
+end
diff --git a/spec/graphql/types/alert_management/integration_type_enum_spec.rb b/spec/graphql/types/alert_management/integration_type_enum_spec.rb
new file mode 100644
index 00000000000..0cdd67cb140
--- /dev/null
+++ b/spec/graphql/types/alert_management/integration_type_enum_spec.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AlertManagementIntegrationType'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementIntegrationType') }
+
+ describe 'statuses' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:name, :value) do
+ 'PROMETHEUS' | :prometheus
+ 'HTTP' | :http
+ end
+
+ with_them do
+ it 'exposes a type with the correct value' do
+ expect(described_class.values[name].value).to eq(value)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/alert_management/integration_type_spec.rb b/spec/graphql/types/alert_management/integration_type_spec.rb
new file mode 100644
index 00000000000..5d149e6da6e
--- /dev/null
+++ b/spec/graphql/types/alert_management/integration_type_spec.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AlertManagementIntegration'] do
+ specify { expect(described_class.graphql_name).to eq('AlertManagementIntegration') }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ id
+ type
+ name
+ active
+ token
+ url
+ api_url
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb
new file mode 100644
index 00000000000..0e9994035d8
--- /dev/null
+++ b/spec/graphql/types/alert_management/prometheus_integration_type_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AlertManagementPrometheusIntegration'] do
+ include GraphqlHelpers
+
+ specify { expect(described_class.graphql_name).to eq('AlertManagementPrometheusIntegration') }
+ specify { expect(described_class).to require_graphql_authorizations(:admin_project) }
+
+ describe 'resolvers' do
+ shared_examples_for 'has field with value' do |field_name|
+ it 'correctly renders the field' do
+ expect(resolve_field(field_name, integration)).to eq(value)
+ end
+ end
+
+ let_it_be_with_reload(:integration) { create(:prometheus_service) }
+
+ it_behaves_like 'has field with value', 'name' do
+ let(:value) { integration.title }
+ end
+
+ it_behaves_like 'has field with value', 'type' do
+ let(:value) { :prometheus }
+ end
+
+ it_behaves_like 'has field with value', 'token' do
+ let(:value) { nil }
+ end
+
+ it_behaves_like 'has field with value', 'url' do
+ let(:value) { "http://localhost/#{integration.project.full_path}/prometheus/alerts/notify.json" }
+ end
+
+ it_behaves_like 'has field with value', 'active' do
+ let(:value) { integration.manual_configuration? }
+ end
+
+ context 'with alerting setting' do
+ let_it_be(:alerting_setting) { create(:project_alerting_setting, project: integration.project) }
+
+ it_behaves_like 'has field with value', 'token' do
+ let(:value) { alerting_setting.token }
+ end
+ end
+
+ context 'without project' do
+ let_it_be(:integration) { create(:prometheus_service, project: nil, group: create(:group)) }
+
+ it_behaves_like 'has field with value', 'token' do
+ let(:value) { nil }
+ end
+
+ it_behaves_like 'has field with value', 'url' do
+ let(:value) { nil }
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/availability_enum_spec.rb b/spec/graphql/types/availability_enum_spec.rb
new file mode 100644
index 00000000000..a9bdf5e4da6
--- /dev/null
+++ b/spec/graphql/types/availability_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['AvailabilityEnum'] do
+ specify { expect(described_class.graphql_name).to eq('AvailabilityEnum') }
+
+ it 'exposes all the existing access levels' do
+ expect(described_class.values.keys).to match_array(%w[NOT_SET BUSY])
+ end
+end
diff --git a/spec/graphql/types/ci/detailed_status_type_spec.rb b/spec/graphql/types/ci/detailed_status_type_spec.rb
index ddb3a1450df..9fa3280657a 100644
--- a/spec/graphql/types/ci/detailed_status_type_spec.rb
+++ b/spec/graphql/types/ci/detailed_status_type_spec.rb
@@ -3,11 +3,30 @@
require 'spec_helper'
RSpec.describe Types::Ci::DetailedStatusType do
+ include GraphqlHelpers
+
specify { expect(described_class.graphql_name).to eq('DetailedStatus') }
- it "has all fields" do
+ it 'has all fields' do
expect(described_class).to have_graphql_fields(:group, :icon, :favicon,
:details_path, :has_details,
:label, :text, :tooltip, :action)
end
+
+ describe 'action field' do
+ it 'correctly renders the field' do
+ stage = create(:ci_stage_entity, status: :skipped)
+ status = stage.detailed_status(stage.pipeline.user)
+
+ expected_status = {
+ button_title: status.action_button_title,
+ icon: status.action_icon,
+ method: status.action_method,
+ path: status.action_path,
+ title: status.action_title
+ }
+
+ expect(resolve_field('action', status)).to eq(expected_status)
+ end
+ end
end
diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb
index 3a54ed2efed..3dcb81eefbf 100644
--- a/spec/graphql/types/ci/job_type_spec.rb
+++ b/spec/graphql/types/ci/job_type_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Types::Ci::JobType do
it 'exposes the expected fields' do
expected_fields = %i[
+ pipeline
name
needs
detailedStatus
diff --git a/spec/graphql/types/ci/runner_setup_type_spec.rb b/spec/graphql/types/ci/runner_setup_type_spec.rb
new file mode 100644
index 00000000000..197e717e964
--- /dev/null
+++ b/spec/graphql/types/ci/runner_setup_type_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::Ci::RunnerSetupType do
+ specify { expect(described_class.graphql_name).to eq('RunnerSetup') }
+
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ install_instructions
+ register_instructions
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/commit_type_spec.rb b/spec/graphql/types/commit_type_spec.rb
index d222287270d..e9bc7f6bb94 100644
--- a/spec/graphql/types/commit_type_spec.rb
+++ b/spec/graphql/types/commit_type_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe GitlabSchema.types['Commit'] do
it 'contains attributes related to commit' do
expect(described_class).to have_graphql_fields(
:id, :sha, :title, :description, :description_html, :message, :title_html, :authored_date,
- :author_name, :author_gravatar, :author, :web_url, :web_path, :latest_pipeline,
+ :author_name, :author_gravatar, :author, :web_url, :web_path,
:pipelines, :signature_html
)
end
diff --git a/spec/graphql/types/container_repository_cleanup_status_enum_spec.rb b/spec/graphql/types/container_repository_cleanup_status_enum_spec.rb
new file mode 100644
index 00000000000..36cfc789ee9
--- /dev/null
+++ b/spec/graphql/types/container_repository_cleanup_status_enum_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRepositoryCleanupStatus'] do
+ it 'exposes all statuses' do
+ expected_keys = ContainerRepository.expiration_policy_cleanup_statuses
+ .keys
+ .map { |k| k.gsub('cleanup_', '') }
+ .map(&:upcase)
+ expect(described_class.values.keys).to contain_exactly(*expected_keys)
+ end
+end
diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb
new file mode 100644
index 00000000000..b5ff460fcf7
--- /dev/null
+++ b/spec/graphql/types/container_repository_details_type_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do
+ fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status tags]
+
+ it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') }
+
+ it { expect(described_class.description).to eq('Details of a container repository') }
+
+ it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
+
+ it { expect(described_class).to have_graphql_fields(fields) }
+
+ describe 'tags field' do
+ subject { described_class.fields['tags'] }
+
+ it 'returns tags connection type' do
+ is_expected.to have_graphql_type(Types::ContainerRepositoryTagType.connection_type)
+ end
+ end
+end
diff --git a/spec/graphql/types/container_repository_status_enum_spec.rb b/spec/graphql/types/container_repository_status_enum_spec.rb
new file mode 100644
index 00000000000..9598879779a
--- /dev/null
+++ b/spec/graphql/types/container_repository_status_enum_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRepositoryStatus'] do
+ it 'exposes all statuses' do
+ expect(described_class.values.keys).to contain_exactly(*ContainerRepository.statuses.keys.map(&:upcase))
+ end
+end
diff --git a/spec/graphql/types/container_repository_tag_type_spec.rb b/spec/graphql/types/container_repository_tag_type_spec.rb
new file mode 100644
index 00000000000..1d1a76d6916
--- /dev/null
+++ b/spec/graphql/types/container_repository_tag_type_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRepositoryTag'] do
+ fields = %i[name path location digest revision short_revision total_size created_at can_delete]
+
+ it { expect(described_class.graphql_name).to eq('ContainerRepositoryTag') }
+
+ it { expect(described_class.description).to eq('A tag from a container repository') }
+
+ it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
+
+ it { expect(described_class).to have_graphql_fields(fields) }
+end
diff --git a/spec/graphql/types/container_repository_type_spec.rb b/spec/graphql/types/container_repository_type_spec.rb
new file mode 100644
index 00000000000..3d3445ba5c3
--- /dev/null
+++ b/spec/graphql/types/container_repository_type_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['ContainerRepository'] do
+ fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete expiration_policy_cleanup_status]
+
+ it { expect(described_class.graphql_name).to eq('ContainerRepository') }
+
+ it { expect(described_class.description).to eq('A container repository') }
+
+ it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
+
+ it { expect(described_class).to have_graphql_fields(fields) }
+
+ describe 'status field' do
+ subject { described_class.fields['status'] }
+
+ it 'returns status enum' do
+ is_expected.to have_graphql_type(Types::ContainerRepositoryStatusEnum)
+ end
+ end
+
+ describe 'expiration_policy_cleanup_status field' do
+ subject { described_class.fields['expirationPolicyCleanupStatus'] }
+
+ it 'returns cleanup status enum' do
+ is_expected.to have_graphql_type(Types::ContainerRepositoryCleanupStatusEnum)
+ end
+ end
+end
diff --git a/spec/graphql/types/countable_connection_type_spec.rb b/spec/graphql/types/countable_connection_type_spec.rb
index af34611ecfe..3b3c02baa5d 100644
--- a/spec/graphql/types/countable_connection_type_spec.rb
+++ b/spec/graphql/types/countable_connection_type_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe GitlabSchema.types['IssueConnection'] do
+RSpec.describe GitlabSchema.types['MergeRequestConnection'] do
it 'has the expected fields' do
expected_fields = %i[count page_info edges nodes]
diff --git a/spec/graphql/types/custom_emoji_type_spec.rb b/spec/graphql/types/custom_emoji_type_spec.rb
new file mode 100644
index 00000000000..7f3c99e4b63
--- /dev/null
+++ b/spec/graphql/types/custom_emoji_type_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['CustomEmoji'] do
+ specify { expect(described_class.graphql_name).to eq('CustomEmoji') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_custom_emoji) }
+
+ specify { expect(described_class).to have_graphql_fields(:id, :name, :url, :external) }
+end
diff --git a/spec/graphql/types/environment_type_spec.rb b/spec/graphql/types/environment_type_spec.rb
index 2220f847e4e..3671d35e8a5 100644
--- a/spec/graphql/types/environment_type_spec.rb
+++ b/spec/graphql/types/environment_type_spec.rb
@@ -44,18 +44,12 @@ RSpec.describe GitlabSchema.types['Environment'] do
expect(subject['data']['project']['environment']['name']).to eq(environment.name)
end
- it 'returns the path when the feature is enabled' do
+ it 'returns the path to the environment' do
expect(subject['data']['project']['environment']['path']).to eq(
Gitlab::Routing.url_helpers.project_environment_path(project, environment)
)
end
- it 'does not return the path when the feature is disabled' do
- stub_feature_flags(expose_environment_path_in_alert_details: false)
-
- expect(subject['data']['project']['environment']['path']).to be_nil
- end
-
context 'when query alert data for the environment' do
let_it_be(:query) do
%(
diff --git a/spec/graphql/types/global_id_type_spec.rb b/spec/graphql/types/global_id_type_spec.rb
index 7589b0e285e..cb129868f7e 100644
--- a/spec/graphql/types/global_id_type_spec.rb
+++ b/spec/graphql/types/global_id_type_spec.rb
@@ -45,8 +45,7 @@ RSpec.describe Types::GlobalIDType do
end
it 'rejects nil' do
- expect { described_class.coerce_isolated_input(nil) }
- .to raise_error(GraphQL::CoercionError)
+ expect(described_class.coerce_isolated_input(nil)).to be_nil
end
it 'rejects gids from different apps' do
diff --git a/spec/graphql/types/grafana_integration_type_spec.rb b/spec/graphql/types/grafana_integration_type_spec.rb
index b4658db08d7..816264c36c8 100644
--- a/spec/graphql/types/grafana_integration_type_spec.rb
+++ b/spec/graphql/types/grafana_integration_type_spec.rb
@@ -7,7 +7,6 @@ RSpec.describe GitlabSchema.types['GrafanaIntegration'] do
%i[
id
grafana_url
- token
enabled
created_at
updated_at
diff --git a/spec/graphql/types/group_invitation_type_spec.rb b/spec/graphql/types/group_invitation_type_spec.rb
new file mode 100644
index 00000000000..dab2d43fc90
--- /dev/null
+++ b/spec/graphql/types/group_invitation_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::GroupInvitationType do
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Group) }
+
+ specify { expect(described_class.graphql_name).to eq('GroupInvitation') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_group) }
+
+ it 'has the expected fields' do
+ expected_fields = %w[
+ email access_level created_by created_at updated_at expires_at group
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/invitation_interface_spec.rb b/spec/graphql/types/invitation_interface_spec.rb
new file mode 100644
index 00000000000..8f345c58ca3
--- /dev/null
+++ b/spec/graphql/types/invitation_interface_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::InvitationInterface do
+ it 'exposes the expected fields' do
+ expected_fields = %i[
+ email
+ access_level
+ created_by
+ created_at
+ updated_at
+ expires_at
+ user
+ ]
+
+ expect(described_class).to have_graphql_fields(*expected_fields)
+ end
+
+ describe '.resolve_type' do
+ subject { described_class.resolve_type(object, {}) }
+
+ context 'for project member' do
+ let(:object) { build(:project_member) }
+
+ it { is_expected.to be Types::ProjectInvitationType }
+ end
+
+ context 'for group member' do
+ let(:object) { build(:group_member) }
+
+ it { is_expected.to be Types::GroupInvitationType }
+ end
+
+ context 'for an unknown type' do
+ let(:object) { build(:user) }
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::BaseError)
+ end
+ end
+ end
+end
diff --git a/spec/graphql/types/issue_type_spec.rb b/spec/graphql/types/issue_type_spec.rb
index c55e624dd11..558fc479af1 100644
--- a/spec/graphql/types/issue_type_spec.rb
+++ b/spec/graphql/types/issue_type_spec.rb
@@ -14,10 +14,10 @@ RSpec.describe GitlabSchema.types['Issue'] do
specify { expect(described_class.interfaces).to include(Types::CurrentUserTodos) }
it 'has specific fields' do
- fields = %i[id iid title description state reference author assignees participants labels milestone due_date
- confidential discussion_locked upvotes downvotes user_notes_count web_path web_url relative_position
- subscribed time_estimate total_time_spent closed_at created_at updated_at task_completion_status
- designs design_collection alert_management_alert severity current_user_todos]
+ fields = %i[id iid title description state reference author assignees updated_by participants labels milestone due_date
+ confidential discussion_locked upvotes downvotes user_notes_count user_discussions_count web_path web_url relative_position
+ emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status
+ design_collection alert_management_alert severity current_user_todos moved moved_to]
fields.each do |field_name|
expect(described_class).to have_graphql_field(field_name)
diff --git a/spec/graphql/types/merge_request_type_spec.rb b/spec/graphql/types/merge_request_type_spec.rb
index 9d901655b7b..8800250b103 100644
--- a/spec/graphql/types/merge_request_type_spec.rb
+++ b/spec/graphql/types/merge_request_type_spec.rb
@@ -17,11 +17,11 @@ RSpec.describe GitlabSchema.types['MergeRequest'] do
description_html state created_at updated_at source_project target_project
project project_id source_project_id target_project_id source_branch
target_branch work_in_progress merge_when_pipeline_succeeds diff_head_sha
- merge_commit_sha user_notes_count should_remove_source_branch
+ merge_commit_sha user_notes_count user_discussions_count should_remove_source_branch
diff_refs diff_stats diff_stats_summary
force_remove_source_branch merge_status in_progress_merge_commit_sha
merge_error allow_collaboration should_be_rebased rebase_commit_sha
- rebase_in_progress merge_commit_message default_merge_commit_message
+ rebase_in_progress default_merge_commit_message
merge_ongoing mergeable_discussions_state web_url
source_branch_exists target_branch_exists
upvotes downvotes head_pipeline pipelines task_completion_status
diff --git a/spec/graphql/types/permission_types/note_spec.rb b/spec/graphql/types/permission_types/note_spec.rb
index 9769c7b3aa3..d75284951c7 100644
--- a/spec/graphql/types/permission_types/note_spec.rb
+++ b/spec/graphql/types/permission_types/note_spec.rb
@@ -5,9 +5,9 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['NotePermissions'] do
it 'has the expected fields' do
expected_permissions = [
- :read_note, :create_note, :admin_note, :resolve_note, :award_emoji
+ :read_note, :create_note, :admin_note, :resolve_note, :reposition_note, :award_emoji
]
- expect(described_class).to have_graphql_fields(expected_permissions)
+ expect(described_class).to have_graphql_fields(expected_permissions).only
end
end
diff --git a/spec/graphql/types/project_invitation_type_spec.rb b/spec/graphql/types/project_invitation_type_spec.rb
new file mode 100644
index 00000000000..148a763a5fa
--- /dev/null
+++ b/spec/graphql/types/project_invitation_type_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::ProjectInvitationType do
+ specify { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Project) }
+
+ specify { expect(described_class.graphql_name).to eq('ProjectInvitation') }
+
+ specify { expect(described_class).to require_graphql_authorizations(:read_project) }
+
+ it 'has the expected fields' do
+ expected_fields = %w[
+ access_level created_by created_at updated_at expires_at project user
+ ]
+
+ expect(described_class).to include_graphql_fields(*expected_fields)
+ end
+end
diff --git a/spec/graphql/types/project_statistics_type_spec.rb b/spec/graphql/types/project_statistics_type_spec.rb
index e6cffd407de..407ce82e73a 100644
--- a/spec/graphql/types/project_statistics_type_spec.rb
+++ b/spec/graphql/types/project_statistics_type_spec.rb
@@ -6,6 +6,6 @@ RSpec.describe GitlabSchema.types['ProjectStatistics'] do
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :commit_count,
- :wiki_size, :snippets_size)
+ :wiki_size, :snippets_size, :uploads_size)
end
end
diff --git a/spec/graphql/types/project_type_spec.rb b/spec/graphql/types/project_type_spec.rb
index 8aa9e1138cc..be579e92fb3 100644
--- a/spec/graphql/types/project_type_spec.rb
+++ b/spec/graphql/types/project_type_spec.rb
@@ -27,7 +27,8 @@ RSpec.describe GitlabSchema.types['Project'] do
environment boards jira_import_status jira_imports services releases release
alert_management_alerts alert_management_alert alert_management_alert_status_counts
container_expiration_policy service_desk_enabled service_desk_address
- issue_status_counts terraform_states
+ issue_status_counts terraform_states alert_management_integrations
+
]
expect(described_class).to include_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/projects/services_enum_spec.rb b/spec/graphql/types/projects/services_enum_spec.rb
index dac1213daf3..b8da9305de4 100644
--- a/spec/graphql/types/projects/services_enum_spec.rb
+++ b/spec/graphql/types/projects/services_enum_spec.rb
@@ -11,5 +11,5 @@ RSpec.describe GitlabSchema.types['ServiceType'] do
end
def available_services_enum
- ::Service.services_types.map(&:underscore).map(&:upcase)
+ ::Service.available_services_types(include_dev: false).map(&:underscore).map(&:upcase)
end
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index 1d9ca8323f8..7a0b3035607 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -80,4 +80,18 @@ RSpec.describe GitlabSchema.types['Query'] do
is_expected.to have_graphql_type(Types::Ci::RunnerPlatformType.connection_type)
end
end
+
+ describe 'runner_setup field' do
+ subject { described_class.fields['runnerSetup'] }
+
+ it 'returns runner setup instructions' do
+ is_expected.to have_graphql_type(Types::Ci::RunnerSetupType)
+ end
+ end
+
+ describe 'container_repository field' do
+ subject { described_class.fields['containerRepository'] }
+
+ it { is_expected.to have_graphql_type(Types::ContainerRepositoryDetailsType) }
+ end
end
diff --git a/spec/graphql/types/release_asset_link_input_type_spec.rb b/spec/graphql/types/release_asset_link_input_type_spec.rb
new file mode 100644
index 00000000000..d97a91b609a
--- /dev/null
+++ b/spec/graphql/types/release_asset_link_input_type_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::ReleaseAssetLinkInputType do
+ specify { expect(described_class.graphql_name).to eq('ReleaseAssetLinkInput') }
+
+ it 'has the correct arguments' do
+ expect(described_class.arguments.keys).to match_array(%w[name url directAssetPath linkType])
+ end
+
+ it 'sets the type of link_type argument to ReleaseAssetLinkTypeEnum' do
+ expect(described_class.arguments['linkType'].type).to eq(Types::ReleaseAssetLinkTypeEnum)
+ end
+end
diff --git a/spec/graphql/types/release_assets_input_type_spec.rb b/spec/graphql/types/release_assets_input_type_spec.rb
new file mode 100644
index 00000000000..c44abe1e171
--- /dev/null
+++ b/spec/graphql/types/release_assets_input_type_spec.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Types::ReleaseAssetsInputType do
+ specify { expect(described_class.graphql_name).to eq('ReleaseAssetsInput') }
+
+ it 'has the correct arguments' do
+ expect(described_class.arguments.keys).to match_array(%w[links])
+ end
+
+ it 'sets the type of links argument to ReleaseAssetLinkInputType' do
+ expect(described_class.arguments['links'].type.of_type.of_type).to eq(Types::ReleaseAssetLinkInputType)
+ end
+end
diff --git a/spec/graphql/types/release_links_type_spec.rb b/spec/graphql/types/release_links_type_spec.rb
index d505f0a4b5c..38c38d58baa 100644
--- a/spec/graphql/types/release_links_type_spec.rb
+++ b/spec/graphql/types/release_links_type_spec.rb
@@ -8,8 +8,11 @@ RSpec.describe GitlabSchema.types['ReleaseLinks'] do
it 'has the expected fields' do
expected_fields = %w[
selfUrl
- mergeRequestsUrl
- issuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ openedIssuesUrl
+ closedIssuesUrl
editUrl
]
diff --git a/spec/graphql/types/root_storage_statistics_type_spec.rb b/spec/graphql/types/root_storage_statistics_type_spec.rb
index 79d474f13ad..4fef8f6eafd 100644
--- a/spec/graphql/types/root_storage_statistics_type_spec.rb
+++ b/spec/graphql/types/root_storage_statistics_type_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe GitlabSchema.types['RootStorageStatistics'] do
it 'has all the required fields' do
expect(described_class).to have_graphql_fields(:storage_size, :repository_size, :lfs_objects_size,
:build_artifacts_size, :packages_size, :wiki_size, :snippets_size,
- :pipeline_artifacts_size)
+ :pipeline_artifacts_size, :uploads_size)
end
specify { expect(described_class).to require_graphql_authorizations(:read_statistics) }
diff --git a/spec/graphql/types/security/report_types_enum_spec.rb b/spec/graphql/types/security/report_types_enum_spec.rb
new file mode 100644
index 00000000000..2da852606c7
--- /dev/null
+++ b/spec/graphql/types/security/report_types_enum_spec.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['SecurityReportTypeEnum'] do
+ it 'exposes all security report types' do
+ expect(described_class.values.keys).to contain_exactly(
+ *::Security::SecurityJobsFinder.allowed_job_types.map(&:to_s).map(&:upcase)
+ )
+ end
+end
diff --git a/spec/graphql/types/terraform/state_type_spec.rb b/spec/graphql/types/terraform/state_type_spec.rb
index 51508208046..9f65bb926d7 100644
--- a/spec/graphql/types/terraform/state_type_spec.rb
+++ b/spec/graphql/types/terraform/state_type_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['TerraformState'] do
it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
describe 'fields' do
- let(:fields) { %i[id name locked_by_user locked_at created_at updated_at] }
+ let(:fields) { %i[id name locked_by_user locked_at latest_version created_at updated_at] }
it { expect(described_class).to have_graphql_fields(fields) }
@@ -17,5 +17,8 @@ RSpec.describe GitlabSchema.types['TerraformState'] do
it { expect(described_class.fields['lockedAt'].type).not_to be_non_null }
it { expect(described_class.fields['createdAt'].type).to be_non_null }
it { expect(described_class.fields['updatedAt'].type).to be_non_null }
+
+ it { expect(described_class.fields['latestVersion'].type).not_to be_non_null }
+ it { expect(described_class.fields['latestVersion'].complexity).to eq(3) }
end
end
diff --git a/spec/graphql/types/terraform/state_version_type_spec.rb b/spec/graphql/types/terraform/state_version_type_spec.rb
new file mode 100644
index 00000000000..1c1e95039dc
--- /dev/null
+++ b/spec/graphql/types/terraform/state_version_type_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe GitlabSchema.types['TerraformStateVersion'] do
+ it { expect(described_class.graphql_name).to eq('TerraformStateVersion') }
+ it { expect(described_class).to require_graphql_authorizations(:read_terraform_state) }
+
+ describe 'fields' do
+ let(:fields) { %i[id created_by_user job created_at updated_at] }
+
+ it { expect(described_class).to have_graphql_fields(fields) }
+
+ it { expect(described_class.fields['id'].type).to be_non_null }
+ it { expect(described_class.fields['createdByUser'].type).not_to be_non_null }
+ it { expect(described_class.fields['job'].type).not_to be_non_null }
+ it { expect(described_class.fields['createdAt'].type).to be_non_null }
+ it { expect(described_class.fields['updatedAt'].type).to be_non_null }
+ end
+end
diff --git a/spec/graphql/types/user_status_type_spec.rb b/spec/graphql/types/user_status_type_spec.rb
index c4421a9cc10..ced9c40d552 100644
--- a/spec/graphql/types/user_status_type_spec.rb
+++ b/spec/graphql/types/user_status_type_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe Types::UserStatusType do
emoji
message
message_html
+ availability
]
expect(described_class).to have_graphql_fields(*expected_fields)
diff --git a/spec/graphql/types/user_type_spec.rb b/spec/graphql/types/user_type_spec.rb
index 1d5af24b3d9..c8953d9ccb7 100644
--- a/spec/graphql/types/user_type_spec.rb
+++ b/spec/graphql/types/user_type_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe GitlabSchema.types['User'] do
authoredMergeRequests
assignedMergeRequests
groupMemberships
+ groupCount
projectMemberships
starredProjects
]
diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb
index 7f25721801f..479e2d7ef9d 100644
--- a/spec/helpers/application_settings_helper_spec.rb
+++ b/spec/helpers/application_settings_helper_spec.rb
@@ -166,4 +166,32 @@ RSpec.describe ApplicationSettingsHelper do
it { is_expected.to eq(false) }
end
end
+
+ describe '.signup_enabled?' do
+ subject { helper.signup_enabled? }
+
+ context 'when signup is enabled' do
+ before do
+ stub_application_setting(signup_enabled: true)
+ end
+
+ it { is_expected.to be true }
+ end
+
+ context 'when signup is disabled' do
+ before do
+ stub_application_setting(signup_enabled: false)
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'when `signup_enabled` is nil' do
+ before do
+ stub_application_setting(signup_enabled: nil)
+ end
+
+ it { is_expected.to be false }
+ end
+ end
end
diff --git a/spec/helpers/auth_helper_spec.rb b/spec/helpers/auth_helper_spec.rb
index e0d316baa17..b4cea7fb695 100644
--- a/spec/helpers/auth_helper_spec.rb
+++ b/spec/helpers/auth_helper_spec.rb
@@ -260,4 +260,41 @@ RSpec.describe AuthHelper do
end
end
end
+
+ describe '#google_tag_manager_enabled?' do
+ let(:is_gitlab_com) { true }
+ let(:user) { nil }
+
+ before do
+ allow(Gitlab).to receive(:com?).and_return(is_gitlab_com)
+ stub_config(extra: { google_tag_manager_id: 'key' })
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ subject(:google_tag_manager_enabled?) { helper.google_tag_manager_enabled? }
+
+ context 'on gitlab.com and a key set without a current user' do
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when not on gitlab.com' do
+ let(:is_gitlab_com) { false }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when current user is set' do
+ let(:user) { instance_double('User') }
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when no key is set' do
+ before do
+ stub_config(extra: {})
+ end
+
+ it { is_expected.to be_falsey }
+ end
+ end
end
diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb
index baa97781efa..cafe4c4275e 100644
--- a/spec/helpers/blob_helper_spec.rb
+++ b/spec/helpers/blob_helper_spec.rb
@@ -236,11 +236,7 @@ RSpec.describe BlobHelper do
let(:data) { File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml')) }
let(:blob) { fake_blob(path: Gitlab::FileDetector::PATTERNS[:gitlab_ci], data: data) }
- context 'experiment enabled' do
- before do
- allow(helper).to receive(:experiment_enabled?).and_return(true)
- end
-
+ context 'feature enabled' do
it 'is true' do
expect(helper.show_suggest_pipeline_creation_celebration?).to be_truthy
end
@@ -284,9 +280,9 @@ RSpec.describe BlobHelper do
end
end
- context 'experiment disabled' do
+ context 'feature disabled' do
before do
- allow(helper).to receive(:experiment_enabled?).and_return(false)
+ stub_feature_flags(suggest_pipeline: false)
end
it 'is false' do
@@ -298,11 +294,7 @@ RSpec.describe BlobHelper do
context 'when file is not a pipeline config file' do
let(:blob) { fake_blob(path: 'LICENSE') }
- context 'experiment enabled' do
- before do
- allow(helper).to receive(:experiment_enabled?).and_return(true)
- end
-
+ context 'feature enabled' do
it 'is false' do
expect(helper.show_suggest_pipeline_creation_celebration?).to be_falsey
end
@@ -444,6 +436,55 @@ RSpec.describe BlobHelper do
end
end
+ describe '#ide_merge_request_path' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project)}
+
+ it 'returns IDE path for the given MR if MR is not merged' do
+ expect(helper.ide_merge_request_path(merge_request)).to eq("/-/ide/project/#{project.full_path}/merge_requests/#{merge_request.iid}")
+ end
+
+ context 'when the MR comes from a fork' do
+ include ProjectForksHelper
+
+ let(:forked_project) { fork_project(project, nil, repository: true) }
+ let(:merge_request) { create(:merge_request, source_project: forked_project, target_project: project) }
+
+ it 'returns IDE path for MR in the forked repo with target project included as param' do
+ expect(helper.ide_merge_request_path(merge_request)).to eq("/-/ide/project/#{forked_project.full_path}/merge_requests/#{merge_request.iid}?target_project=#{CGI.escape(project.full_path)}")
+ end
+ end
+
+ context 'when the MR is merged' do
+ let(:current_user) { build(:user) }
+
+ let_it_be(:merge_request) { create(:merge_request, :merged, source_project: project, source_branch: 'testing-1', target_branch: 'feature-1') }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(current_user)
+ allow(helper).to receive(:can?).and_return(true)
+ end
+
+ it 'returns default IDE url with master branch' do
+ expect(helper.ide_merge_request_path(merge_request)).to eq("/-/ide/project/#{project.full_path}/edit/master")
+ end
+
+ it 'includes file path passed' do
+ expect(helper.ide_merge_request_path(merge_request, 'README.md')).to eq("/-/ide/project/#{project.full_path}/edit/master/-/README.md")
+ end
+
+ context 'when target branch exists' do
+ before do
+ allow(merge_request).to receive(:target_branch_exists?).and_return(true)
+ end
+
+ it 'returns IDE edit url with the target branch' do
+ expect(helper.ide_merge_request_path(merge_request)).to eq("/-/ide/project/#{project.full_path}/edit/feature-1")
+ end
+ end
+ end
+ end
+
describe '#ide_fork_and_edit_path' do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
diff --git a/spec/helpers/branches_helper_spec.rb b/spec/helpers/branches_helper_spec.rb
index 1f7bf25afcd..2ad15adff59 100644
--- a/spec/helpers/branches_helper_spec.rb
+++ b/spec/helpers/branches_helper_spec.rb
@@ -28,5 +28,23 @@ RSpec.describe BranchesHelper do
expect(subject).to eq(expected_array)
end
end
+
+ context 'when an access level tied to a deploy key is provided' do
+ let!(:protected_branch) { create(:protected_branch, :no_one_can_push) }
+ let!(:deploy_key) { create(:deploy_key, deploy_keys_projects: [create(:deploy_keys_project, :write_access, project: protected_branch.project)]) }
+
+ let(:push_level) { protected_branch.push_access_levels.first }
+ let(:deploy_key_push_level) { create(:protected_branch_push_access_level, protected_branch: protected_branch, deploy_key: deploy_key) }
+ let(:access_levels) { [push_level, deploy_key_push_level] }
+
+ it 'returns the correct array' do
+ expected_array = [
+ { id: push_level.id, type: :role, access_level: Gitlab::Access::NO_ACCESS },
+ { id: deploy_key_push_level.id, type: :deploy_key, deploy_key_id: deploy_key.id }
+ ]
+
+ expect(subject).to eq(expected_array)
+ end
+ end
end
end
diff --git a/spec/helpers/breadcrumbs_helper_spec.rb b/spec/helpers/breadcrumbs_helper_spec.rb
new file mode 100644
index 00000000000..8e2a684656b
--- /dev/null
+++ b/spec/helpers/breadcrumbs_helper_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BreadcrumbsHelper do
+ describe '#push_to_schema_breadcrumb' do
+ let(:element_name) { 'BreadCrumbElement' }
+ let(:link) { 'http://test.host/foo' }
+ let(:breadcrumb_list) { helper.instance_variable_get(:@schema_breadcrumb_list) }
+
+ subject { helper.push_to_schema_breadcrumb(element_name, link) }
+
+ it 'enqueue element name, link and position' do
+ subject
+
+ aggregate_failures do
+ expect(breadcrumb_list[0]['name']).to eq element_name
+ expect(breadcrumb_list[0]['item']).to eq link
+ expect(breadcrumb_list[0]['position']).to eq(1)
+ end
+ end
+
+ context 'when link is relative' do
+ let(:link) { '/foo' }
+
+ it 'converts the url into absolute' do
+ subject
+
+ expect(breadcrumb_list[0]['item']).to eq "http://test.host#{link}"
+ end
+ end
+
+ describe 'when link is invalid' do
+ let(:link) { 'invalid://foo[]' }
+
+ it 'returns the current url' do
+ subject
+
+ expect(breadcrumb_list[0]['item']).to eq 'http://test.host'
+ end
+ end
+
+ describe 'when link is nil' do
+ let(:link) { nil }
+
+ it 'returns the current url' do
+ subject
+
+ expect(breadcrumb_list[0]['item']).to eq 'http://test.host'
+ end
+ end
+ end
+
+ describe '#schema_breadcrumb_json' do
+ let(:elements) do
+ [
+ %w(element1 http://test.host/link1),
+ %w(element2 http://test.host/link2)
+ ]
+ end
+
+ subject { helper.schema_breadcrumb_json }
+
+ it 'returns the breadcrumb schema in json format' do
+ enqueue_breadcrumb_elements
+
+ expected_result = {
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => [
+ {
+ '@type' => 'ListItem',
+ 'position' => 1,
+ 'name' => elements[0][0],
+ 'item' => elements[0][1]
+ },
+ {
+ '@type' => 'ListItem',
+ 'position' => 2,
+ 'name' => elements[1][0],
+ 'item' => elements[1][1]
+ }
+ ]
+ }.to_json
+
+ expect(subject).to eq expected_result
+ end
+
+ context 'when extra breadcrumb element is added' do
+ let(:extra_elements) do
+ [
+ %w(extra_element1 http://test.host/extra_link1),
+ %w(extra_element2 http://test.host/extra_link2)
+ ]
+ end
+
+ it 'include the extra elements before the last element' do
+ enqueue_breadcrumb_elements
+
+ extra_elements.each do |el|
+ add_to_breadcrumbs(el[0], el[1])
+ end
+
+ expected_result = {
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => [
+ {
+ '@type' => 'ListItem',
+ 'position' => 1,
+ 'name' => elements[0][0],
+ 'item' => elements[0][1]
+ },
+ {
+ '@type' => 'ListItem',
+ 'position' => 2,
+ 'name' => extra_elements[0][0],
+ 'item' => extra_elements[0][1]
+ },
+ {
+ '@type' => 'ListItem',
+ 'position' => 3,
+ 'name' => extra_elements[1][0],
+ 'item' => extra_elements[1][1]
+ },
+ {
+ '@type' => 'ListItem',
+ 'position' => 4,
+ 'name' => elements[1][0],
+ 'item' => elements[1][1]
+ }
+ ]
+ }.to_json
+
+ expect(subject).to eq expected_result
+ end
+ end
+
+ def enqueue_breadcrumb_elements
+ elements.each do |el|
+ helper.push_to_schema_breadcrumb(el[0], el[1])
+ end
+ end
+ end
+end
diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb
new file mode 100644
index 00000000000..8f38d3b1439
--- /dev/null
+++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::PipelineEditorHelper do
+ let_it_be(:project) { create(:project) }
+
+ describe 'can_view_pipeline_editor?' do
+ subject { helper.can_view_pipeline_editor?(project) }
+
+ it 'user can view editor if they can collaborate' do
+ allow(helper).to receive(:can_collaborate_with_project?).and_return(true)
+
+ expect(subject).to be true
+ end
+
+ it 'user can not view editor if they cannot collaborate' do
+ allow(helper).to receive(:can_collaborate_with_project?).and_return(false)
+
+ expect(subject).to be false
+ end
+
+ it 'user can not view editor if feature is disabled' do
+ allow(helper).to receive(:can_collaborate_with_project?).and_return(true)
+ stub_feature_flags(ci_pipeline_editor_page: false)
+
+ expect(subject).to be false
+ end
+ end
+end
diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb
index ef1f0940074..c085c3bdbd9 100644
--- a/spec/helpers/diff_helper_spec.rb
+++ b/spec/helpers/diff_helper_spec.rb
@@ -130,6 +130,38 @@ RSpec.describe DiffHelper do
end
end
+ describe "#diff_link_number" do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:line) do
+ double(:line, type: line_type)
+ end
+
+ # This helper is used to generate the line numbers on the
+ # diff lines. It essentially just returns a blank string
+ # on the old/new lines. The following table tests all the
+ # possible permutations for clarity.
+
+ where(:line_type, :match, :line_number, :expected_return_value) do
+ "new" | "new" | 1 | " "
+ "new" | "old" | 2 | 2
+ "old" | "new" | 3 | 3
+ "old" | "old" | 4 | " "
+ "new-nonewline" | "new" | 5 | 5
+ "new-nonewline" | "old" | 6 | 6
+ "old-nonewline" | "new" | 7 | 7
+ "old-nonewline" | "old" | 8 | 8
+ "match" | "new" | 9 | 9
+ "match" | "old" | 10 | 10
+ end
+
+ with_them do
+ it "returns the expected value" do
+ expect(helper.diff_link_number(line.type, match, line_number)).to eq(expected_return_value)
+ end
+ end
+ end
+
describe "#mark_inline_diffs" do
let(:old_line) { %{abc 'def'} }
let(:new_line) { %{abc "def"} }
@@ -326,4 +358,30 @@ RSpec.describe DiffHelper do
expect(diff_file_path_text(diff_file, max: 10)).to eq("...open.rb")
end
end
+
+ describe 'unified_diff_lines_view_type' do
+ before do
+ controller.params[:view] = 'parallel'
+ end
+
+ describe 'unified diffs enabled' do
+ before do
+ stub_feature_flags(unified_diff_lines: true)
+ end
+
+ it 'returns inline view' do
+ expect(helper.unified_diff_lines_view_type(project)).to eq 'inline'
+ end
+ end
+
+ describe 'unified diffs disabled' do
+ before do
+ stub_feature_flags(unified_diff_lines: false)
+ end
+
+ it 'returns parallel view' do
+ expect(helper.unified_diff_lines_view_type(project)).to eq :parallel
+ end
+ end
+ end
end
diff --git a/spec/helpers/dropdowns_helper_spec.rb b/spec/helpers/dropdowns_helper_spec.rb
index fd1125d0024..c30cb159803 100644
--- a/spec/helpers/dropdowns_helper_spec.rb
+++ b/spec/helpers/dropdowns_helper_spec.rb
@@ -246,8 +246,8 @@ RSpec.describe DropdownsHelper do
expect(content).to include('dropdown-loading')
end
- it 'returns an icon in the content' do
- expect(content.scan('icon').count).to eq(1)
+ it 'returns a gl-spinner in the content' do
+ expect(content).to include('gl-spinner')
end
end
end
diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb
index 0088f739879..f23ffcee35d 100644
--- a/spec/helpers/gitlab_routing_helper_spec.rb
+++ b/spec/helpers/gitlab_routing_helper_spec.rb
@@ -322,4 +322,14 @@ RSpec.describe GitlabRoutingHelper do
end
end
end
+
+ context 'releases' do
+ let(:release) { create(:release) }
+
+ describe '#release_url' do
+ it 'returns the url for the release page' do
+ expect(release_url(release)).to eq("http://test.host/#{release.project.full_path}/-/releases/#{release.tag}")
+ end
+ end
+ end
end
diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb
index 08b25d64b43..8eb1b7b3b3d 100644
--- a/spec/helpers/groups_helper_spec.rb
+++ b/spec/helpers/groups_helper_spec.rb
@@ -87,15 +87,26 @@ RSpec.describe GroupsHelper do
end
describe 'group_title' do
- let(:group) { create(:group) }
- let(:nested_group) { create(:group, parent: group) }
- let(:deep_nested_group) { create(:group, parent: nested_group) }
- let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:nested_group) { create(:group, parent: group) }
+ let_it_be(:deep_nested_group) { create(:group, parent: nested_group) }
+ let_it_be(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
+
+ subject { helper.group_title(very_deep_nested_group) }
it 'outputs the groups in the correct order' do
- expect(helper.group_title(very_deep_nested_group))
+ expect(subject)
.to match(%r{<li style="text-indent: 16px;"><a.*>#{deep_nested_group.name}.*</li>.*<a.*>#{very_deep_nested_group.name}</a>}m)
end
+
+ it 'enqueues the elements in the breadcrumb schema list' do
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(group.name, group_path(group))
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(nested_group.name, group_path(nested_group))
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(deep_nested_group.name, group_path(deep_nested_group))
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(very_deep_nested_group.name, group_path(very_deep_nested_group))
+
+ subject
+ end
end
# rubocop:disable Layout/SpaceBeforeComma
@@ -370,6 +381,26 @@ RSpec.describe GroupsHelper do
end
end
+ describe '#show_thanks_for_purchase_banner?' do
+ subject { helper.show_thanks_for_purchase_banner? }
+
+ it 'returns true with purchased_quantity present in params' do
+ allow(controller).to receive(:params) { { purchased_quantity: '1' } }
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns false with purchased_quantity not present in params' do
+ is_expected.to be_falsey
+ end
+
+ it 'returns false with purchased_quantity is empty in params' do
+ allow(controller).to receive(:params) { { purchased_quantity: '' } }
+
+ is_expected.to be_falsey
+ end
+ end
+
describe '#show_invite_banner?' do
let_it_be(:current_user) { create(:user) }
let_it_be_with_refind(:group) { create(:group) }
diff --git a/spec/helpers/icons_helper_spec.rb b/spec/helpers/icons_helper_spec.rb
index 94012de3877..c05b2b206cc 100644
--- a/spec/helpers/icons_helper_spec.rb
+++ b/spec/helpers/icons_helper_spec.rb
@@ -97,19 +97,19 @@ RSpec.describe IconsHelper do
it 'returns right icon name for standard auth' do
icon_name = 'standard'
expect(audit_icon(icon_name).to_s)
- .to eq '<i class="fa fa-key"></i>'
+ .to eq sprite_icon('key')
end
it 'returns right icon name for two-factor auth' do
icon_name = 'two-factor'
expect(audit_icon(icon_name).to_s)
- .to eq '<i class="fa fa-key"></i>'
+ .to eq sprite_icon('key')
end
it 'returns right icon name for google_oauth2 auth' do
icon_name = 'google_oauth2'
expect(audit_icon(icon_name).to_s)
- .to eq '<i class="fa fa-google"></i>'
+ .to eq sprite_icon('google')
end
end
diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb
index b4e05d67553..d75b3c9f2e3 100644
--- a/spec/helpers/invite_members_helper_spec.rb
+++ b/spec/helpers/invite_members_helper_spec.rb
@@ -7,70 +7,110 @@ RSpec.describe InviteMembersHelper do
let_it_be(:developer) { create(:user, developer_projects: [project]) }
let(:owner) { project.owner }
- before do
- assign(:project, project)
- end
+ context 'with project' do
+ before do
+ assign(:project, project)
+ end
- describe "#directly_invite_members?" do
- context 'when the user is an owner' do
- before do
- allow(helper).to receive(:current_user) { owner }
- end
+ describe "#directly_invite_members?" do
+ context 'when the user is an owner' do
+ before do
+ allow(helper).to receive(:current_user) { owner }
+ end
+
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { false }
- it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { false }
+ expect(helper.directly_invite_members?).to eq false
+ end
- expect(helper.directly_invite_members?).to eq false
+ it 'returns true' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
+
+ expect(helper.directly_invite_members?).to eq true
+ end
end
- it 'returns true' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
+ context 'when the user is a developer' do
+ before do
+ allow(helper).to receive(:current_user) { developer }
+ end
+
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
- expect(helper.directly_invite_members?).to eq true
+ expect(helper.directly_invite_members?).to eq false
+ end
end
end
- context 'when the user is a developer' do
- before do
- allow(helper).to receive(:current_user) { developer }
+ describe "#indirectly_invite_members?" do
+ context 'when a user is a developer' do
+ before do
+ allow(helper).to receive(:current_user) { developer }
+ end
+
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { false }
+
+ expect(helper.indirectly_invite_members?).to eq false
+ end
+
+ it 'returns true' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
+
+ expect(helper.indirectly_invite_members?).to eq true
+ end
end
- it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_a) { true }
+ context 'when a user is an owner' do
+ before do
+ allow(helper).to receive(:current_user) { owner }
+ end
- expect(helper.directly_invite_members?).to eq false
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
+
+ expect(helper.indirectly_invite_members?).to eq false
+ end
end
end
end
- describe "#indirectly_invite_members?" do
- context 'when a user is a developer' do
- before do
- allow(helper).to receive(:current_user) { developer }
- end
+ context 'with group' do
+ let_it_be(:group) { create(:group) }
- it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { false }
+ describe "#invite_group_members?" do
+ context 'when the user is an owner' do
+ before do
+ group.add_owner(owner)
+ allow(helper).to receive(:current_user) { owner }
+ end
- expect(helper.indirectly_invite_members?).to eq false
- end
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_empty_group_version_a) { false }
- it 'returns true' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
+ expect(helper.invite_group_members?(group)).to eq false
+ end
- expect(helper.indirectly_invite_members?).to eq true
- end
- end
+ it 'returns true' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_empty_group_version_a) { true }
- context 'when a user is an owner' do
- before do
- allow(helper).to receive(:current_user) { owner }
+ expect(helper.invite_group_members?(group)).to eq true
+ end
end
- it 'returns false' do
- allow(helper).to receive(:experiment_enabled?).with(:invite_members_version_b) { true }
+ context 'when the user is a developer' do
+ before do
+ group.add_developer(developer)
+ allow(helper).to receive(:current_user) { developer }
+ end
+
+ it 'returns false' do
+ allow(helper).to receive(:experiment_enabled?).with(:invite_members_empty_group_version_a) { true }
- expect(helper.indirectly_invite_members?).to eq false
+ expect(helper.invite_group_members?(group)).to eq false
+ end
end
end
end
diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb
index e8e5adaa274..0e3752f220e 100644
--- a/spec/helpers/issuables_helper_spec.rb
+++ b/spec/helpers/issuables_helper_spec.rb
@@ -345,42 +345,29 @@ RSpec.describe IssuablesHelper do
end
end
- describe '#sidebar_milestone_tooltip_label' do
- it 'escapes HTML in the milestone title' do
- milestone = build(:milestone, title: '&lt;img onerror=alert(1)&gt;')
+ describe '#issuable_display_type' do
+ using RSpec::Parameterized::TableSyntax
- expect(helper.sidebar_milestone_tooltip_label(milestone)).to eq('&lt;img onerror=alert(1)&gt;<br/>Milestone')
+ where(:issuable_type, :issuable_display_type) do
+ :issue | 'issue'
+ :incident | 'incident'
+ :merge_request | 'merge request'
end
- end
-
- describe '#serialize_issuable' do
- context 'when it is a merge request' do
- let(:merge_request) { build(:merge_request) }
- let(:user) { build(:user) }
-
- before do
- allow(helper).to receive(:current_user) { user }
- end
-
- it 'has suggest_pipeline experiment enabled' do
- allow(helper).to receive(:experiment_enabled?).with(:suggest_pipeline) { true }
- expect_next_instance_of(MergeRequestSerializer) do |serializer|
- expect(serializer).to receive(:represent).with(merge_request, { serializer: 'widget', experiment_enabled: :suggest_pipeline })
- end
+ with_them do
+ let(:issuable) { build_stubbed(issuable_type) }
- helper.serialize_issuable(merge_request, serializer: 'widget')
- end
+ subject { helper.issuable_display_type(issuable) }
- it 'suggest_pipeline experiment disabled' do
- allow(helper).to receive(:experiment_enabled?).with(:suggest_pipeline) { false }
+ it { is_expected.to eq(issuable_display_type) }
+ end
+ end
- expect_next_instance_of(MergeRequestSerializer) do |serializer|
- expect(serializer).to receive(:represent).with(merge_request, { serializer: 'widget' })
- end
+ describe '#sidebar_milestone_tooltip_label' do
+ it 'escapes HTML in the milestone title' do
+ milestone = build(:milestone, title: '&lt;img onerror=alert(1)&gt;')
- helper.serialize_issuable(merge_request, serializer: 'widget')
- end
+ expect(helper.sidebar_milestone_tooltip_label(milestone)).to eq('&lt;img onerror=alert(1)&gt;<br/>Milestone')
end
end
end
diff --git a/spec/helpers/markup_helper_spec.rb b/spec/helpers/markup_helper_spec.rb
index 302ab0cc137..6c5855eeb91 100644
--- a/spec/helpers/markup_helper_spec.rb
+++ b/spec/helpers/markup_helper_spec.rb
@@ -9,6 +9,7 @@ RSpec.describe MarkupHelper do
project.add_maintainer(user)
user
end
+
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let_it_be(:snippet) { create(:project_snippet, project: project) }
diff --git a/spec/helpers/operations_helper_spec.rb b/spec/helpers/operations_helper_spec.rb
index 8d2fc643caa..09f9bba8f9e 100644
--- a/spec/helpers/operations_helper_spec.rb
+++ b/spec/helpers/operations_helper_spec.rb
@@ -43,7 +43,9 @@ RSpec.describe OperationsHelper do
'prometheus_api_url' => nil,
'prometheus_activated' => 'false',
'prometheus_url' => notify_project_prometheus_alerts_url(project, format: :json),
- 'disabled' => 'false'
+ 'disabled' => 'false',
+ 'project_path' => project.full_path,
+ 'multi_integrations' => 'false'
)
end
end
diff --git a/spec/helpers/page_layout_helper_spec.rb b/spec/helpers/page_layout_helper_spec.rb
index e8a5c4613fe..99cdee6dbb2 100644
--- a/spec/helpers/page_layout_helper_spec.rb
+++ b/spec/helpers/page_layout_helper_spec.rb
@@ -56,19 +56,24 @@ RSpec.describe PageLayoutHelper do
end
%w(project user group).each do |type|
+ let(:object) { build(type, trait) }
+ let(:trait) { :with_avatar }
+
context "with @#{type} assigned" do
- it "uses #{type.titlecase} avatar if available" do
- object = double(avatar_url: 'http://example.com/uploads/-/system/avatar.png')
+ before do
assign(type, object)
+ end
- expect(helper.page_image).to eq object.avatar_url
+ it "uses #{type.titlecase} avatar full url" do
+ expect(helper.page_image).to eq object.avatar_url(only_path: false)
end
- it 'falls back to the default when avatar_url is nil' do
- object = double(avatar_url: nil)
- assign(type, object)
+ context 'when avatar_url is nil' do
+ let(:trait) { nil }
- expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
+ it 'falls back to the default when avatar_url is nil' do
+ expect(helper.page_image).to match_asset_path 'assets/gitlab_logo.png'
+ end
end
end
@@ -132,4 +137,126 @@ RSpec.describe PageLayoutHelper do
end
end
end
+
+ describe '#page_canonical_link' do
+ let(:user) { build(:user) }
+
+ subject { helper.page_canonical_link(link) }
+
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ context 'when link is passed' do
+ let(:link) { 'https://gitlab.com' }
+
+ it 'stores and returns the link value' do
+ expect(subject).to eq link
+ expect(helper.page_canonical_link(nil)).to eq link
+ end
+ end
+
+ context 'when no link is provided' do
+ let(:link) { nil }
+ let(:request) { ActionDispatch::Request.new(env) }
+ let(:env) do
+ {
+ 'ORIGINAL_FULLPATH' => '/foo/',
+ 'PATH_INFO' => '/foo',
+ 'HTTP_HOST' => 'test.host',
+ 'REQUEST_METHOD' => method,
+ 'rack.url_scheme' => 'http'
+ }
+ end
+
+ before do
+ allow(helper).to receive(:request).and_return(request)
+ end
+
+ shared_examples 'generates the canonical url using the params in the context' do
+ specify { expect(subject).to eq 'http://test.host/foo' }
+ end
+
+ shared_examples 'does not return a canonical url' do
+ specify { expect(subject).to be_nil }
+ end
+
+ it_behaves_like 'generates the canonical url using the params in the context' do
+ let(:method) { 'GET' }
+ end
+
+ it_behaves_like 'generates the canonical url using the params in the context' do
+ let(:method) { 'HEAD' }
+ end
+
+ it_behaves_like 'does not return a canonical url' do
+ let(:method) { 'POST' }
+ end
+
+ it_behaves_like 'does not return a canonical url' do
+ let(:method) { 'PUT' }
+ end
+ end
+ end
+
+ describe '#page_itemtype' do
+ subject { helper.page_itemtype(itemtype) }
+
+ context 'when itemtype is passed' do
+ let(:itemtype) { 'http://schema.org/Person' }
+
+ it 'stores and returns the itemtype value' do
+ attrs = { itemscope: true, itemtype: itemtype }
+
+ expect(subject).to eq attrs
+ expect(helper.page_itemtype(nil)).to eq attrs
+ end
+ end
+
+ context 'when no itemtype is provided' do
+ let(:itemtype) { nil }
+
+ it 'returns an empty hash' do
+ expect(subject).to eq({})
+ end
+ end
+ end
+
+ describe '#user_status_properties' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:user) { build(:user) }
+
+ availability_types = Types::AvailabilityEnum.enum
+
+ where(:message, :emoji, :availability) do
+ "Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:busy]
+ "Some message" | UserStatus::DEFAULT_EMOJI | availability_types[:not_set]
+ "Some message" | "basketball" | availability_types[:busy]
+ "Some message" | "basketball" | availability_types[:not_set]
+ "Some message" | "" | availability_types[:busy]
+ "Some message" | "" | availability_types[:not_set]
+ "" | UserStatus::DEFAULT_EMOJI | availability_types[:busy]
+ "" | UserStatus::DEFAULT_EMOJI | availability_types[:not_set]
+ "" | "basketball" | availability_types[:busy]
+ "" | "basketball" | availability_types[:not_set]
+ "" | "" | availability_types[:busy]
+ "" | "" | availability_types[:not_set]
+ end
+
+ with_them do
+ it "sets the default user status fields" do
+ user.status = UserStatus.new(message: message, emoji: emoji, availability: availability)
+ result = {
+ can_set_user_availability: true,
+ current_availability: availability,
+ current_emoji: emoji,
+ current_message: message,
+ default_emoji: UserStatus::DEFAULT_EMOJI
+ }
+
+ expect(helper.user_status_properties(user)).to eq(result)
+ end
+ end
+ end
end
diff --git a/spec/helpers/profiles_helper_spec.rb b/spec/helpers/profiles_helper_spec.rb
index 61b7ff94edb..9687d038162 100644
--- a/spec/helpers/profiles_helper_spec.rb
+++ b/spec/helpers/profiles_helper_spec.rb
@@ -80,6 +80,38 @@ RSpec.describe ProfilesHelper do
end
end
+ describe "#user_status_set_to_busy?" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:availability, :result) do
+ "busy" | true
+ "not_set" | false
+ "" | false
+ nil | false
+ end
+
+ with_them do
+ it { expect(helper.user_status_set_to_busy?(OpenStruct.new(availability: availability))).to eq(result) }
+ end
+ end
+
+ describe "#show_status_emoji?" do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:message, :emoji, :result) do
+ "Some message" | UserStatus::DEFAULT_EMOJI | true
+ "Some message" | "" | true
+ "" | "basketball" | true
+ "" | "basketball" | true
+ "" | UserStatus::DEFAULT_EMOJI | false
+ "" | UserStatus::DEFAULT_EMOJI | false
+ end
+
+ with_them do
+ it { expect(helper.show_status_emoji?(OpenStruct.new(message: message, emoji: emoji))).to eq(result) }
+ end
+ end
+
def stub_cas_omniauth_provider
provider = OpenStruct.new(
'name' => 'cas3',
diff --git a/spec/helpers/projects/terraform_helper_spec.rb b/spec/helpers/projects/terraform_helper_spec.rb
new file mode 100644
index 00000000000..de363c42d21
--- /dev/null
+++ b/spec/helpers/projects/terraform_helper_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::TerraformHelper do
+ describe '#js_terraform_list_data' do
+ let_it_be(:project) { create(:project) }
+
+ subject { helper.js_terraform_list_data(project) }
+
+ it 'displays image path' do
+ image_path = ActionController::Base.helpers.image_path(
+ 'illustrations/empty-state/empty-serverless-lg.svg'
+ )
+
+ expect(subject[:empty_state_image]).to eq(image_path)
+ end
+
+ it 'displays project path' do
+ expect(subject[:project_path]).to eq(project.full_path)
+ end
+ end
+end
diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb
index f081cf225b1..9635a6f9c82 100644
--- a/spec/helpers/projects_helper_spec.rb
+++ b/spec/helpers/projects_helper_spec.rb
@@ -999,4 +999,15 @@ RSpec.describe ProjectsHelper do
end
end
end
+
+ describe '#project_title' do
+ subject { helper.project_title(project) }
+
+ it 'enqueues the elements in the breadcrumb schema list' do
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(project.namespace.name, user_path(project.owner))
+ expect(helper).to receive(:push_to_schema_breadcrumb).with(project.name, project_path(project))
+
+ subject
+ end
+ end
end
diff --git a/spec/helpers/recaptcha_experiment_helper_spec.rb b/spec/helpers/recaptcha_helper_spec.rb
index e677164c950..e7f9ba5b73a 100644
--- a/spec/helpers/recaptcha_experiment_helper_spec.rb
+++ b/spec/helpers/recaptcha_helper_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe RecaptchaExperimentHelper, type: :helper do
+RSpec.describe RecaptchaHelper, type: :helper do
let(:session) { {} }
before do
diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb
index 704e8dc40cb..e6bf91ceef6 100644
--- a/spec/helpers/releases_helper_spec.rb
+++ b/spec/helpers/releases_helper_spec.rb
@@ -64,12 +64,13 @@ RSpec.describe ReleasesHelper do
describe '#data_for_edit_release_page' do
it 'has the needed data to display the "edit release" page' do
keys = %i(project_id
+ group_id
+ group_milestones_available
project_path
tag_name
markdown_preview_path
markdown_docs_path
releases_page_path
- update_release_api_docs_path
release_assets_docs_path
manage_milestones_path
new_milestone_path)
@@ -81,11 +82,12 @@ RSpec.describe ReleasesHelper do
describe '#data_for_new_release_page' do
it 'has the needed data to display the "new release" page' do
keys = %i(project_id
+ group_id
+ group_milestones_available
project_path
releases_page_path
markdown_preview_path
markdown_docs_path
- update_release_api_docs_path
release_assets_docs_path
manage_milestones_path
new_milestone_path
diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb
index 6fe071521cd..34af3ce7e5e 100644
--- a/spec/helpers/search_helper_spec.rb
+++ b/spec/helpers/search_helper_spec.rb
@@ -73,7 +73,7 @@ RSpec.describe SearchHelper do
expect(result.keys).to match_array(%i[category id label url avatar_url])
end
- it 'includes the users recently viewed issues' do
+ it 'includes the users recently viewed issues', :aggregate_failures do
recent_issues = instance_double(::Gitlab::Search::RecentIssues)
expect(::Gitlab::Search::RecentIssues).to receive(:new).with(user: user).and_return(recent_issues)
project1 = create(:project, :with_avatar, namespace: user.namespace)
@@ -104,7 +104,7 @@ RSpec.describe SearchHelper do
})
end
- it 'includes the users recently viewed merge requests' do
+ it 'includes the users recently viewed merge requests', :aggregate_failures do
recent_merge_requests = instance_double(::Gitlab::Search::RecentMergeRequests)
expect(::Gitlab::Search::RecentMergeRequests).to receive(:new).with(user: user).and_return(recent_merge_requests)
project1 = create(:project, :with_avatar, namespace: user.namespace)
@@ -143,12 +143,44 @@ RSpec.describe SearchHelper do
context "with a current project" do
before do
@project = create(:project, :repository)
+ allow(self).to receive(:can?).with(user, :read_feature_flag, @project).and_return(false)
end
- it "includes project-specific sections" do
+ it "includes project-specific sections", :aggregate_failures do
expect(search_autocomplete_opts("Files").size).to eq(1)
expect(search_autocomplete_opts("Commits").size).to eq(1)
end
+
+ context 'when user does not have access to project' do
+ it 'does not include issues by iid' do
+ issue = create(:issue, project: @project)
+ results = search_autocomplete_opts("\##{issue.iid}")
+
+ expect(results.count).to eq(0)
+ end
+ end
+
+ context 'when user has project access' do
+ before do
+ @project = create(:project, :repository, namespace: user.namespace)
+ allow(self).to receive(:can?).with(user, :read_feature_flag, @project).and_return(true)
+ end
+
+ it 'includes issues by iid', :aggregate_failures do
+ issue = create(:issue, project: @project, title: 'test title')
+ results = search_autocomplete_opts("\##{issue.iid}")
+
+ expect(results.count).to eq(1)
+
+ expect(results.first).to include({
+ category: 'In this project',
+ id: issue.id,
+ label: 'test title (#1)',
+ url: ::Gitlab::Routing.url_helpers.project_issue_path(issue.project, issue),
+ avatar_url: '' # project has no avatar
+ })
+ end
+ end
end
end
@@ -204,11 +236,34 @@ RSpec.describe SearchHelper do
end
describe 'search_entries_empty_message' do
- it 'returns the formatted entry message' do
- message = search_entries_empty_message('projects', '<h1>foo</h1>')
+ let!(:group) { build(:group) }
+ let!(:project) { build(:project, group: group) }
+
+ context 'global search' do
+ let(:message) { search_entries_empty_message('projects', '<h1>foo</h1>', nil, nil) }
+
+ it 'returns the formatted entry message' do
+ expect(message).to eq("We couldn&#39;t find any projects matching <code>&lt;h1&gt;foo&lt;/h1&gt;</code>")
+ expect(message).to be_html_safe
+ end
+ end
+
+ context 'group search' do
+ let(:message) { search_entries_empty_message('projects', '<h1>foo</h1>', group, nil) }
- expect(message).to eq("We couldn't find any projects matching <code>&lt;h1&gt;foo&lt;/h1&gt;</code>")
- expect(message).to be_html_safe
+ it 'returns the formatted entry message' do
+ expect(message).to start_with('We couldn&#39;t find any projects matching <code>&lt;h1&gt;foo&lt;/h1&gt;</code> in group <a')
+ expect(message).to be_html_safe
+ end
+ end
+
+ context 'project search' do
+ let(:message) { search_entries_empty_message('projects', '<h1>foo</h1>', group, project) }
+
+ it 'returns the formatted entry message' do
+ expect(message).to start_with('We couldn&#39;t find any projects matching <code>&lt;h1&gt;foo&lt;/h1&gt;</code> in project <a')
+ expect(message).to be_html_safe
+ end
end
end
@@ -343,6 +398,19 @@ RSpec.describe SearchHelper do
expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello', project_id: 23))
end
+ it 'restricts the params' do
+ expect(self).to receive(:params).and_return(
+ ActionController::Parameters.new(
+ search: 'hello',
+ unknown: 42
+ )
+ )
+
+ link = search_filter_link('projects', 'Projects')
+
+ expect(link).to have_link('Projects', href: search_path(scope: 'projects', search: 'hello'))
+ end
+
it 'assigns given data attributes on the list container' do
link = search_filter_link('projects', 'Projects', data: { foo: 'bar' })
@@ -409,7 +477,7 @@ RSpec.describe SearchHelper do
end
end
- describe '#highlight_and_truncate_issue' do
+ describe '#highlight_and_truncate_issuable' do
let(:description) { 'hello world' }
let(:issue) { create(:issue, description: description) }
let(:user) { create(:user) }
@@ -418,7 +486,7 @@ RSpec.describe SearchHelper do
allow(self).to receive(:current_user).and_return(user)
end
- subject { highlight_and_truncate_issue(issue, 'test', {}) }
+ subject { highlight_and_truncate_issuable(issue, 'test', {}) }
context 'when description is not present' do
let(:description) { nil }
@@ -477,4 +545,38 @@ RSpec.describe SearchHelper do
end
end
end
+
+ describe '#issuable_state_to_badge_class' do
+ context 'with merge request' do
+ it 'returns correct badge based on status' do
+ expect(issuable_state_to_badge_class(build(:merge_request, :merged))).to eq(:primary)
+ expect(issuable_state_to_badge_class(build(:merge_request, :closed))).to eq(:danger)
+ expect(issuable_state_to_badge_class(build(:merge_request, :opened))).to eq(:success)
+ end
+ end
+
+ context 'with an issue' do
+ it 'returns correct badge based on status' do
+ expect(issuable_state_to_badge_class(build(:issue, :closed))).to eq(:info)
+ expect(issuable_state_to_badge_class(build(:issue, :opened))).to eq(:success)
+ end
+ end
+ end
+
+ describe '#issuable_state_text' do
+ context 'with merge request' do
+ it 'returns correct badge based on status' do
+ expect(issuable_state_text(build(:merge_request, :merged))).to eq(_('Merged'))
+ expect(issuable_state_text(build(:merge_request, :closed))).to eq(_('Closed'))
+ expect(issuable_state_text(build(:merge_request, :opened))).to eq(_('Open'))
+ end
+ end
+
+ context 'with an issue' do
+ it 'returns correct badge based on status' do
+ expect(issuable_state_text(build(:issue, :closed))).to eq(_('Closed'))
+ expect(issuable_state_text(build(:issue, :opened))).to eq(_('Open'))
+ end
+ end
+ end
end
diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb
index 6c52016139b..1c300aea2df 100644
--- a/spec/helpers/sorting_helper_spec.rb
+++ b/spec/helpers/sorting_helper_spec.rb
@@ -50,6 +50,24 @@ RSpec.describe SortingHelper do
end
end
+ describe '#search_sort_direction_button' do
+ before do
+ set_sorting_url 'test_label'
+ end
+
+ it 'keeps label filter param' do
+ expect(search_sort_direction_button('created_asc')).to include('label_name=test_label')
+ end
+
+ it 'returns icon with sort-lowest when sort is asc' do
+ expect(search_sort_direction_button('created_asc')).to include('sort-lowest')
+ end
+
+ it 'returns icon with sort-highest when sort is desc' do
+ expect(search_sort_direction_button('created_desc')).to include('sort-highest')
+ end
+ end
+
def stub_controller_path(value)
allow(helper.controller).to receive(:controller_path).and_return(value)
end
diff --git a/spec/helpers/sourcegraph_helper_spec.rb b/spec/helpers/sourcegraph_helper_spec.rb
index 6a95c8e4a43..d03893ea9ae 100644
--- a/spec/helpers/sourcegraph_helper_spec.rb
+++ b/spec/helpers/sourcegraph_helper_spec.rb
@@ -5,60 +5,43 @@ require 'spec_helper'
RSpec.describe SourcegraphHelper do
describe '#sourcegraph_url_message' do
let(:sourcegraph_url) { 'http://sourcegraph.example.com' }
+ let(:feature_conditional) { false }
+ let(:public_only) { false }
+ let(:is_com) { true }
before do
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url).and_return(sourcegraph_url)
allow(Gitlab::CurrentSettings).to receive(:sourcegraph_url_is_com?).and_return(is_com)
+ allow(Gitlab::CurrentSettings).to receive(:sourcegraph_public_only).and_return(public_only)
+ allow(Gitlab::Sourcegraph).to receive(:feature_conditional?).and_return(feature_conditional)
end
subject { helper.sourcegraph_url_message }
context 'with .com sourcegraph url' do
- let(:is_com) { true }
-
- it { is_expected.to have_text('Uses Sourcegraph.com') }
- it { is_expected.to have_link('Sourcegraph.com', href: sourcegraph_url) }
+ it { is_expected.to have_text('Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental.') }
end
context 'with custom sourcegraph url' do
let(:is_com) { false }
- it { is_expected.to have_text('Uses a custom Sourcegraph instance') }
- it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) }
-
- context 'with unsafe url' do
- let(:sourcegraph_url) { '\" onload=\"alert(1);\"' }
-
- it { is_expected.to have_link('Sourcegraph instance', href: sourcegraph_url) }
- end
+ it { is_expected.to have_text('Uses a custom %{linkStart}Sourcegraph instance%{linkEnd}. This feature is experimental.') }
end
- end
-
- describe '#sourcegraph_experimental_message' do
- let(:feature_conditional) { false }
- let(:public_only) { false }
-
- before do
- allow(Gitlab::CurrentSettings).to receive(:sourcegraph_public_only).and_return(public_only)
- allow(Gitlab::Sourcegraph).to receive(:feature_conditional?).and_return(feature_conditional)
- end
-
- subject { helper.sourcegraph_experimental_message }
context 'when not limited by feature or public only' do
- it { is_expected.to eq "This feature is experimental." }
+ it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental.' }
end
context 'when limited by feature' do
let(:feature_conditional) { true }
- it { is_expected.to eq "This feature is experimental and currently limited to certain projects." }
+ it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental and currently limited to certain projects.' }
end
context 'when limited by public only' do
let(:public_only) { true }
- it { is_expected.to eq "This feature is experimental and limited to public projects." }
+ it { is_expected.to eq 'Uses %{linkStart}Sourcegraph.com%{linkEnd}. This feature is experimental and limited to public projects.' }
end
end
end
diff --git a/spec/helpers/stat_anchors_helper_spec.rb b/spec/helpers/stat_anchors_helper_spec.rb
new file mode 100644
index 00000000000..c6556647bc8
--- /dev/null
+++ b/spec/helpers/stat_anchors_helper_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe StatAnchorsHelper do
+ let(:anchor_klass) { ProjectPresenter::AnchorData }
+
+ describe '#stat_anchor_attrs' do
+ subject { helper.stat_anchor_attrs(anchor) }
+
+ context 'when anchor is a link' do
+ let(:anchor) { anchor_klass.new(true) }
+
+ it 'returns the proper attributes' do
+ expect(subject[:class]).to include('stat-link')
+ end
+ end
+
+ context 'when anchor is not a link' do
+ context 'when class_modifier is set' do
+ let(:anchor) { anchor_klass.new(false, nil, nil, 'default') }
+
+ it 'returns the proper attributes' do
+ expect(subject[:class]).to include('btn btn-default')
+ end
+ end
+
+ context 'when class_modifier is not set' do
+ let(:anchor) { anchor_klass.new(false) }
+
+ it 'returns the proper attributes' do
+ expect(subject[:class]).to include('btn btn-missing')
+ end
+ end
+ end
+
+ context 'when itemprop is not set' do
+ let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, false) }
+
+ it 'returns the itemprop attributes' do
+ expect(subject[:itemprop]).to be_nil
+ end
+ end
+
+ context 'when itemprop is set set' do
+ let(:anchor) { anchor_klass.new(false, nil, nil, nil, nil, true) }
+
+ it 'returns the itemprop attributes' do
+ expect(subject[:itemprop]).to eq true
+ end
+ end
+ end
+end
diff --git a/spec/helpers/time_helper_spec.rb b/spec/helpers/time_helper_spec.rb
index 6663a5c81c8..3e406f5e74e 100644
--- a/spec/helpers/time_helper_spec.rb
+++ b/spec/helpers/time_helper_spec.rb
@@ -37,4 +37,14 @@ RSpec.describe TimeHelper do
it { expect(duration_in_numbers(duration)).to eq formatted_string }
end
end
+
+ describe "#time_in_milliseconds" do
+ it "returns the time in milliseconds" do
+ freeze_time do
+ time = (Time.now.to_f * 1000).to_i
+
+ expect(time_in_milliseconds).to eq time
+ end
+ end
+ end
end
diff --git a/spec/helpers/todos_helper_spec.rb b/spec/helpers/todos_helper_spec.rb
index 6b658a475b1..9481d756c16 100644
--- a/spec/helpers/todos_helper_spec.rb
+++ b/spec/helpers/todos_helper_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe TodosHelper do
project: issue.project,
note: 'I am note, hear me roar')
end
+
let_it_be(:design_todo) do
create(:todo, :mentioned,
user: user,
@@ -20,6 +21,7 @@ RSpec.describe TodosHelper do
author: author,
note: note)
end
+
let_it_be(:alert_todo) do
alert = create(:alert_management_alert, iid: 1001)
create(:todo, target: alert)
diff --git a/spec/helpers/tree_helper_spec.rb b/spec/helpers/tree_helper_spec.rb
index b5d356b985c..620bf248d7b 100644
--- a/spec/helpers/tree_helper_spec.rb
+++ b/spec/helpers/tree_helper_spec.rb
@@ -7,6 +7,8 @@ RSpec.describe TreeHelper do
let(:repository) { project.repository }
let(:sha) { 'c1c67abbaf91f624347bb3ae96eabe3a1b742478' }
+ let_it_be(:user) { create(:user) }
+
def create_file(filename)
project.repository.create_file(
project.creator,
@@ -219,7 +221,6 @@ RSpec.describe TreeHelper do
context 'user does not have write access but a personal fork exists' do
include ProjectForksHelper
- let_it_be(:user) { create(:user) }
let(:forked_project) { create(:project, :repository, namespace: user.namespace) }
before do
@@ -277,8 +278,6 @@ RSpec.describe TreeHelper do
end
context 'user has write access' do
- let_it_be(:user) { create(:user) }
-
before do
project.add_developer(user)
@@ -314,8 +313,6 @@ RSpec.describe TreeHelper do
end
context 'gitpod feature is enabled' do
- let_it_be(:user) { create(:user) }
-
before do
stub_feature_flags(gitpod: true)
allow(Gitlab::CurrentSettings)
@@ -358,4 +355,28 @@ RSpec.describe TreeHelper do
end
end
end
+
+ describe '.patch_branch_name' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ end
+
+ subject { helper.patch_branch_name('master') }
+
+ it 'returns a patch branch name' do
+ freeze_time do
+ epoch = Time.now.strftime('%s%L').last(5)
+
+ expect(subject).to eq "#{user.username}-master-patch-#{epoch}"
+ end
+ end
+
+ context 'without a current_user' do
+ let(:user) { nil }
+
+ it 'returns nil' do
+ expect(subject).to be nil
+ end
+ end
+ end
end
diff --git a/spec/helpers/user_callouts_helper_spec.rb b/spec/helpers/user_callouts_helper_spec.rb
index bcb0b5c51e7..4ab3be877b4 100644
--- a/spec/helpers/user_callouts_helper_spec.rb
+++ b/spec/helpers/user_callouts_helper_spec.rb
@@ -161,4 +161,50 @@ RSpec.describe UserCalloutsHelper do
it { is_expected.to be_falsy }
end
end
+
+ describe '.show_registration_enabled_user_callout?' do
+ let_it_be(:admin) { create(:user, :admin) }
+
+ subject { helper.show_registration_enabled_user_callout? }
+
+ context 'when `current_user` is not an admin' do
+ before do
+ allow(helper).to receive(:current_user).and_return(user)
+ stub_application_setting(signup_enabled: true)
+ allow(helper).to receive(:user_dismissed?).with(described_class::REGISTRATION_ENABLED_CALLOUT) { false }
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'when signup is disabled' do
+ before do
+ allow(helper).to receive(:current_user).and_return(admin)
+ stub_application_setting(signup_enabled: false)
+ allow(helper).to receive(:user_dismissed?).with(described_class::REGISTRATION_ENABLED_CALLOUT) { false }
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'when user has dismissed callout' do
+ before do
+ allow(helper).to receive(:current_user).and_return(admin)
+ stub_application_setting(signup_enabled: true)
+ allow(helper).to receive(:user_dismissed?).with(described_class::REGISTRATION_ENABLED_CALLOUT) { true }
+ end
+
+ it { is_expected.to be false }
+ end
+
+ context 'when `current_user` is an admin, signup is enabled, and user has not dismissed callout' do
+ before do
+ allow(helper).to receive(:current_user).and_return(admin)
+ stub_application_setting(signup_enabled: true)
+ allow(helper).to receive(:user_dismissed?).with(described_class::REGISTRATION_ENABLED_CALLOUT) { false }
+ end
+
+ it { is_expected.to be true }
+ end
+ end
end
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index c9dc3fcff3f..9ebbf975903 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -204,40 +204,72 @@ RSpec.describe UsersHelper do
end
describe '#work_information' do
- subject { helper.work_information(user) }
+ let(:with_schema_markup) { false }
- context 'when both job_title and organization are present' do
- let(:user) { build(:user, organization: 'GitLab', job_title: 'Frontend Engineer') }
+ subject { helper.work_information(user, with_schema_markup: with_schema_markup) }
- it 'returns job title concatenated with organization' do
- is_expected.to eq('Frontend Engineer at GitLab')
- end
+ context 'when neither organization nor job_title are present' do
+ it { is_expected.to be_nil }
end
- context 'when only organization is present' do
- let(:user) { build(:user, organization: 'GitLab') }
+ context 'when user parameter is nil' do
+ let(:user) { nil }
- it "returns organization" do
- is_expected.to eq('GitLab')
- end
+ it { is_expected.to be_nil }
end
- context 'when only job_title is present' do
- let(:user) { build(:user, job_title: 'Frontend Engineer') }
+ context 'without schema markup' do
+ context 'when both job_title and organization are present' do
+ let(:user) { build(:user, organization: 'GitLab', job_title: 'Frontend Engineer') }
- it 'returns job title' do
- is_expected.to eq('Frontend Engineer')
+ it 'returns job title concatenated with organization' do
+ is_expected.to eq('Frontend Engineer at GitLab')
+ end
end
- end
- context 'when neither organization nor job_title are present' do
- it { is_expected.to be_nil }
+ context 'when only organization is present' do
+ let(:user) { build(:user, organization: 'GitLab') }
+
+ it "returns organization" do
+ is_expected.to eq('GitLab')
+ end
+ end
+
+ context 'when only job_title is present' do
+ let(:user) { build(:user, job_title: 'Frontend Engineer') }
+
+ it 'returns job title' do
+ is_expected.to eq('Frontend Engineer')
+ end
+ end
end
- context 'when user parameter is nil' do
- let(:user) { nil }
+ context 'with schema markup' do
+ let(:with_schema_markup) { true }
- it { is_expected.to be_nil }
+ context 'when both job_title and organization are present' do
+ let(:user) { build(:user, organization: 'GitLab', job_title: 'Frontend Engineer') }
+
+ it 'returns job title concatenated with organization' do
+ is_expected.to eq('<span itemprop="jobTitle">Frontend Engineer</span> at <span itemprop="worksFor">GitLab</span>')
+ end
+ end
+
+ context 'when only organization is present' do
+ let(:user) { build(:user, organization: 'GitLab') }
+
+ it "returns organization" do
+ is_expected.to eq('<span itemprop="worksFor">GitLab</span>')
+ end
+ end
+
+ context 'when only job_title is present' do
+ let(:user) { build(:user, job_title: 'Frontend Engineer') }
+
+ it 'returns job title' do
+ is_expected.to eq('<span itemprop="jobTitle">Frontend Engineer</span>')
+ end
+ end
end
end
end
diff --git a/spec/helpers/whats_new_helper_spec.rb b/spec/helpers/whats_new_helper_spec.rb
index 80d4ca8ddea..1c8684de75c 100644
--- a/spec/helpers/whats_new_helper_spec.rb
+++ b/spec/helpers/whats_new_helper_spec.rb
@@ -3,21 +3,23 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
+ let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
+
describe '#whats_new_storage_key' do
subject { helper.whats_new_storage_key }
- before do
- allow(helper).to receive(:whats_new_most_recent_version).and_return(version)
- end
-
context 'when version exist' do
- let(:version) { '84.0' }
+ before do
+ allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
+ end
- it { is_expected.to eq('display-whats-new-notification-84.0') }
+ it { is_expected.to eq('display-whats-new-notification-01.05') }
end
context 'when recent release items do NOT exist' do
- let(:version) { nil }
+ before do
+ allow(helper).to receive(:whats_new_release_items).and_return(nil)
+ end
it { is_expected.to be_nil }
end
@@ -27,8 +29,6 @@ RSpec.describe WhatsNewHelper do
subject { helper.whats_new_most_recent_release_items_count }
context 'when recent release items exist' do
- let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
-
it 'returns the count from the most recent file' do
expect(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
@@ -48,4 +48,13 @@ RSpec.describe WhatsNewHelper do
end
end
end
+
+ # Testing this important private method here because the request spec required multiple confusing mocks and felt wrong and overcomplicated
+ describe '#whats_new_items_cache_key' do
+ it 'returns a key containing the most recent file name and page parameter' do
+ allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
+
+ expect(helper.send(:whats_new_items_cache_key, 2)).to eq('whats_new:release_items:file-20201225_01_05:page-2')
+ end
+ end
end
diff --git a/spec/lib/api/entities/merge_request_changes_spec.rb b/spec/lib/api/entities/merge_request_changes_spec.rb
new file mode 100644
index 00000000000..f46d8981328
--- /dev/null
+++ b/spec/lib/api/entities/merge_request_changes_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::API::Entities::MergeRequestChanges do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:merge_request) { create(:merge_request) }
+ let(:entity) { described_class.new(merge_request, current_user: user) }
+
+ subject(:basic_entity) { entity.as_json }
+
+ it "exposes basic entity fields" do
+ is_expected.to include(:changes, :overflow)
+ end
+
+ context "when #expose_raw_diffs? returns false" do
+ before do
+ expect(entity).to receive(:expose_raw_diffs?).twice.and_return(false)
+ expect_any_instance_of(Gitlab::Git::DiffCollection).to receive(:overflow?)
+ end
+
+ it "does not access merge_request.raw_diffs" do
+ expect(merge_request).not_to receive(:raw_diffs)
+
+ basic_entity
+ end
+ end
+
+ context "when #expose_raw_diffs? returns true" do
+ before do
+ expect(entity).to receive(:expose_raw_diffs?).twice.and_return(true)
+ expect_any_instance_of(Gitlab::Git::DiffCollection).not_to receive(:overflow?)
+ end
+
+ it "does not access merge_request.raw_diffs" do
+ expect(merge_request).to receive(:raw_diffs)
+
+ basic_entity
+ end
+ end
+
+ describe ":overflow field" do
+ context "when :access_raw_diffs is true" do
+ let_it_be(:entity_with_raw_diffs) do
+ described_class.new(merge_request, current_user: user, access_raw_diffs: true).as_json
+ end
+
+ before do
+ expect_any_instance_of(Gitlab::Git::DiffCollection).not_to receive(:overflow?)
+ end
+
+ it "reports false" do
+ expect(entity_with_raw_diffs[:overflow]).to be_falsy
+ end
+ end
+ end
+end
diff --git a/spec/lib/api/every_api_endpoint_spec.rb b/spec/lib/api/every_api_endpoint_spec.rb
new file mode 100644
index 00000000000..ebf75e733d0
--- /dev/null
+++ b/spec/lib/api/every_api_endpoint_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Every API endpoint' do
+ context 'feature categories' do
+ let_it_be(:feature_categories) do
+ YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:to_sym).to_set
+ end
+
+ let_it_be(:api_endpoints) do
+ API::API.routes.map do |route|
+ [route.app.options[:for], API::Base.path_for_app(route.app)]
+ end
+ end
+
+ let_it_be(:routes_without_category) do
+ api_endpoints.map do |(klass, path)|
+ next if klass.try(:feature_category_for_action, path)
+
+ "#{klass}##{path}"
+ end.compact.uniq
+ end
+
+ it "has feature categories" do
+ expect(routes_without_category).to be_empty, "#{routes_without_category} did not have a category"
+ end
+
+ it "recognizes the feature categories" do
+ routes_unknown_category = api_endpoints.map do |(klass, path)|
+ used_category = klass.try(:feature_category_for_action, path)
+ next unless used_category
+ next if used_category == :not_owned
+
+ [path, used_category] unless feature_categories.include?(used_category)
+ end.compact
+
+ expect(routes_unknown_category).to be_empty, "#{routes_unknown_category.first(10)} had an unknown category"
+ end
+
+ # This is required for API::Base.path_for_app to work, as it picks
+ # the first path
+ it "has no routes with multiple paths" do
+ routes_with_multiple_paths = API::API.routes.select { |route| route.app.options[:path].length != 1 }
+ failure_routes = routes_with_multiple_paths.map { |route| "#{route.app.options[:for]}:[#{route.app.options[:path].join(', ')}]" }
+
+ expect(routes_with_multiple_paths).to be_empty, "#{failure_routes} have multiple paths"
+ end
+
+ it "doesn't define or exclude categories on removed actions", :aggregate_failures do
+ api_endpoints.group_by(&:first).each do |klass, paths|
+ existing_paths = paths.map(&:last)
+ used_paths = paths_defined_in_feature_category_config(klass)
+ non_existing_used_paths = used_paths - existing_paths
+
+ expect(non_existing_used_paths).to be_empty,
+ "#{klass} used #{non_existing_used_paths} to define feature category, but the route does not exist"
+ end
+ end
+ end
+
+ def paths_defined_in_feature_category_config(klass)
+ (klass.try(:class_attributes) || {}).fetch(:feature_category_config, {})
+ .values
+ .flatten
+ .map(&:to_s)
+ end
+end
diff --git a/spec/lib/api/helpers_spec.rb b/spec/lib/api/helpers_spec.rb
index 8e738af0fa3..be5f0cc9f9a 100644
--- a/spec/lib/api/helpers_spec.rb
+++ b/spec/lib/api/helpers_spec.rb
@@ -176,10 +176,10 @@ RSpec.describe API::Helpers do
end
describe '#track_event' do
- it "creates a gitlab tracking event" do
- expect(Gitlab::Tracking).to receive(:event).with('foo', 'my_event', {})
-
+ it "creates a gitlab tracking event", :snowplow do
subject.track_event('my_event', category: 'foo')
+
+ expect_snowplow_event(category: 'foo', action: 'my_event')
end
it "logs an exception" do
diff --git a/spec/lib/api/validations/validators/email_or_email_list_spec.rb b/spec/lib/api/validations/validators/email_or_email_list_spec.rb
new file mode 100644
index 00000000000..ac3111c2319
--- /dev/null
+++ b/spec/lib/api/validations/validators/email_or_email_list_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Validations::Validators::EmailOrEmailList do
+ include ApiValidatorsHelpers
+
+ subject do
+ described_class.new(['email'], {}, false, scope.new)
+ end
+
+ context 'with valid email addresses' do
+ it 'does not raise a validation error' do
+ expect_no_validation_error('test' => 'test@example.org')
+ expect_no_validation_error('test' => 'test1@example.com,test2@example.org')
+ expect_no_validation_error('test' => 'test1@example.com,test2@example.org,test3@example.co.uk')
+ end
+ end
+
+ context 'including any invalid email address' do
+ it 'raises a validation error' do
+ expect_validation_error('test' => 'not')
+ expect_validation_error('test' => '@example.com')
+ expect_validation_error('test' => 'test1@example.com,asdf')
+ expect_validation_error('test' => 'asdf,testa1@example.com,asdf')
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/client_spec.rb b/spec/lib/atlassian/jira_connect/client_spec.rb
index 40ffec21b26..cefd1fa3274 100644
--- a/spec/lib/atlassian/jira_connect/client_spec.rb
+++ b/spec/lib/atlassian/jira_connect/client_spec.rb
@@ -11,9 +11,20 @@ RSpec.describe Atlassian::JiraConnect::Client do
Timecop.freeze { example.run }
end
+ describe '.generate_update_sequence_id' do
+ it 'returns monotonic_time converted it to integer' do
+ allow(Gitlab::Metrics::System).to receive(:monotonic_time).and_return(1.0)
+
+ expect(described_class.generate_update_sequence_id).to eq(1)
+ end
+ end
+
describe '#store_dev_info' do
- it "calls the API with auth headers" do
- expected_jwt = Atlassian::Jwt.encode(
+ let_it_be(:project) { create_default(:project, :repository) }
+ let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
+
+ let(:expected_jwt) do
+ Atlassian::Jwt.encode(
Atlassian::Jwt.build_claims(
Atlassian::JiraConnect.app_key,
'/rest/devinfo/0.10/bulk',
@@ -21,7 +32,9 @@ RSpec.describe Atlassian::JiraConnect::Client do
),
'sample_secret'
)
+ end
+ before do
stub_full_request('https://gitlab-test.atlassian.net/rest/devinfo/0.10/bulk', method: :post)
.with(
headers: {
@@ -29,8 +42,18 @@ RSpec.describe Atlassian::JiraConnect::Client do
'Content-Type' => 'application/json'
}
)
+ end
+
+ it "calls the API with auth headers" do
+ subject.store_dev_info(project: project)
+ end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new { subject.store_dev_info(project: project, merge_requests: merge_requests) }.count
+
+ merge_requests << create(:merge_request, :unique_branches)
- subject.store_dev_info(project: create(:project))
+ expect { subject.store_dev_info(project: project, merge_requests: merge_requests) }.not_to exceed_query_limit(control_count)
end
end
end
diff --git a/spec/lib/atlassian/jira_connect/serializers/base_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/base_entity_spec.rb
new file mode 100644
index 00000000000..d7672c0baf1
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/base_entity_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::BaseEntity do
+ let(:update_sequence_id) { nil }
+
+ subject do
+ described_class.represent(
+ anything,
+ update_sequence_id: update_sequence_id
+ )
+ end
+
+ it 'generates the update_sequence_id' do
+ allow(Atlassian::JiraConnect::Client).to receive(:generate_update_sequence_id).and_return(1)
+
+ expect(subject.value_for(:updateSequenceId)).to eq(1)
+ end
+
+ context 'with update_sequence_id option' do
+ let(:update_sequence_id) { 123 }
+
+ it 'uses the custom update_sequence_id' do
+ expect(subject.value_for(:updateSequenceId)).to eq(123)
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb
new file mode 100644
index 00000000000..872ba1ab43d
--- /dev/null
+++ b/spec/lib/atlassian/jira_connect/serializers/pull_request_entity_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Atlassian::JiraConnect::Serializers::PullRequestEntity do
+ let_it_be(:project) { create_default(:project, :repository) }
+ let_it_be(:merge_requests) { create_list(:merge_request, 2, :unique_branches) }
+ let_it_be(:notes) { create_list(:note, 2, system: false, noteable: merge_requests.first) }
+
+ subject { described_class.represent(merge_requests).as_json }
+
+ it 'exposes commentCount' do
+ expect(subject.first[:commentCount]).to eq(2)
+ end
+
+ context 'with user_notes_count option' do
+ let(:user_notes_count) { merge_requests.map { |merge_request| [merge_request.id, 1] }.to_h }
+
+ subject { described_class.represent(merge_requests, user_notes_count: user_notes_count).as_json }
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ described_class.represent(merge_requests, user_notes_count: user_notes_count)
+ end.count
+
+ merge_requests << create(:merge_request, :unique_branches)
+
+ expect { subject }.not_to exceed_query_limit(control_count)
+ end
+
+ it 'uses counts from user_notes_count' do
+ expect(subject.map { |entity| entity[:commentCount] }).to match_array([1, 1, 1])
+ end
+
+ context 'when count is missing for some MRs' do
+ let(:user_notes_count) { [[merge_requests.last.id, 1]].to_h }
+
+ it 'uses 0 as default when count for the MR is not available' do
+ expect(subject.map { |entity| entity[:commentCount] }).to match_array([0, 0, 1])
+ end
+ end
+ end
+end
diff --git a/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
index 23ba1770827..9100398ecc5 100644
--- a/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
+++ b/spec/lib/atlassian/jira_connect/serializers/repository_entity_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Atlassian::JiraConnect::Serializers::RepositoryEntity do
+ let(:update_sequence_id) { nil }
+
subject do
project = create(:project, :repository)
commits = [project.commit]
@@ -13,9 +15,23 @@ RSpec.describe Atlassian::JiraConnect::Serializers::RepositoryEntity do
project,
commits: commits,
branches: branches,
- merge_requests: merge_requests
+ merge_requests: merge_requests,
+ update_sequence_id: update_sequence_id
).to_json
end
it { is_expected.to match_schema('jira_connect/repository') }
+
+ context 'with custom update_sequence_id' do
+ let(:update_sequence_id) { 1.0 }
+
+ it 'passes the update_sequence_id on to the nested entities', :aggregate_failures do
+ parsed_subject = Gitlab::Json.parse(subject)
+
+ expect(parsed_subject['updateSequenceId']).to eq(update_sequence_id)
+ expect(parsed_subject['commits'].first['updateSequenceId']).to eq(update_sequence_id)
+ expect(parsed_subject['branches'].first['updateSequenceId']).to eq(update_sequence_id)
+ expect(parsed_subject['pullRequests'].first['updateSequenceId']).to eq(update_sequence_id)
+ end
+ end
end
diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb
index 2a3f1949ba5..5a965030b01 100644
--- a/spec/lib/backup/artifacts_spec.rb
+++ b/spec/lib/backup/artifacts_spec.rb
@@ -30,7 +30,8 @@ RSpec.describe Backup::Artifacts do
it 'excludes tmp from backup tar' do
expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/gitlab-artifacts -cf - .), 'gzip -c -1'], any_args)
+ expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/gitlab-artifacts -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
+ expect(backup).to receive(:pipeline_succeeded?).and_return(true)
backup.dump
end
end
diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb
index 45cc73974d6..dbc04704fba 100644
--- a/spec/lib/backup/files_spec.rb
+++ b/spec/lib/backup/files_spec.rb
@@ -6,6 +6,10 @@ RSpec.describe Backup::Files do
let(:progress) { StringIO.new }
let!(:project) { create(:project) }
+ let(:status_0) { double('exit 0', success?: true, exitstatus: 0) }
+ let(:status_1) { double('exit 1', success?: false, exitstatus: 1) }
+ let(:status_2) { double('exit 2', success?: false, exitstatus: 2) }
+
before do
allow(progress).to receive(:puts)
allow(progress).to receive(:print)
@@ -24,6 +28,20 @@ RSpec.describe Backup::Files do
allow_any_instance_of(described_class).to receive(:progress).and_return(progress)
end
+ RSpec::Matchers.define :eq_statuslist do |expected|
+ match do |actual|
+ actual.map(&:exitstatus) == expected.map(&:exitstatus)
+ end
+
+ description do
+ 'be an Array of Process::Status with equal exitstatus against expected'
+ end
+
+ failure_message do |actual|
+ "expected #{actual} exitstatuses list to be equal #{expected} exitstatuses list"
+ end
+ end
+
describe '#restore' do
subject { described_class.new('registry', '/var/gitlab-registry') }
@@ -35,8 +53,9 @@ RSpec.describe Backup::Files do
describe 'folders with permission' do
before do
- allow(subject).to receive(:run_pipeline!).and_return(true)
+ allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
allow(subject).to receive(:backup_existing_files).and_return(true)
+ allow(subject).to receive(:pipeline_succeeded?).and_return(true)
allow(Dir).to receive(:glob).with("/var/gitlab-registry/*", File::FNM_DOTMATCH).and_return(["/var/gitlab-registry/.", "/var/gitlab-registry/..", "/var/gitlab-registry/sample1"])
end
@@ -54,14 +73,22 @@ RSpec.describe Backup::Files do
expect(subject).to receive(:tar).and_return('blabla-tar')
expect(subject).to receive(:run_pipeline!).with([%w(gzip -cd), %w(blabla-tar --unlink-first --recursive-unlink -C /var/gitlab-registry -xf -)], any_args)
+ expect(subject).to receive(:pipeline_succeeded?).and_return(true)
subject.restore
end
+
+ it 'raises an error on failure' do
+ expect(subject).to receive(:pipeline_succeeded?).and_return(false)
+
+ expect { subject.restore }.to raise_error(/Restore operation failed:/)
+ end
end
describe 'folders without permissions' do
before do
allow(FileUtils).to receive(:mv).and_raise(Errno::EACCES)
- allow(subject).to receive(:run_pipeline!).and_return(true)
+ allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
+ allow(subject).to receive(:pipeline_succeeded?).and_return(true)
end
it 'shows error message' do
@@ -73,7 +100,8 @@ RSpec.describe Backup::Files do
describe 'folders that are a mountpoint' do
before do
allow(FileUtils).to receive(:mv).and_raise(Errno::EBUSY)
- allow(subject).to receive(:run_pipeline!).and_return(true)
+ allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
+ allow(subject).to receive(:pipeline_succeeded?).and_return(true)
end
it 'shows error message' do
@@ -89,7 +117,8 @@ RSpec.describe Backup::Files do
subject { described_class.new('pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) }
before do
- allow(subject).to receive(:run_pipeline!).and_return(true)
+ allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
+ allow(subject).to receive(:pipeline_succeeded?).and_return(true)
end
it 'raises no errors' do
@@ -103,29 +132,190 @@ RSpec.describe Backup::Files do
subject.dump
end
+ it 'raises an error on failure' do
+ allow(subject).to receive(:run_pipeline!).and_return([[true, true], ''])
+ expect(subject).to receive(:pipeline_succeeded?).and_return(false)
+
+ expect do
+ subject.dump
+ end.to raise_error(/Backup operation failed:/)
+ end
+
describe 'with STRATEGY=copy' do
before do
stub_env('STRATEGY', 'copy')
- end
-
- it 'excludes tmp dirs from rsync' do
allow(Gitlab.config.backup).to receive(:path) { '/var/gitlab-backup' }
allow(File).to receive(:realpath).with("/var/gitlab-backup").and_return("/var/gitlab-backup")
+ end
+ it 'excludes tmp dirs from rsync' do
expect(Gitlab::Popen).to receive(:popen).with(%w(rsync -a --exclude=lost+found --exclude=/@pages.tmp /var/gitlab-pages /var/gitlab-backup)).and_return(['', 0])
subject.dump
end
+
+ it 'raises an error and outputs an error message if rsync failed' do
+ allow(Gitlab::Popen).to receive(:popen).with(%w(rsync -a --exclude=lost+found --exclude=/@pages.tmp /var/gitlab-pages /var/gitlab-backup)).and_return(['rsync failed', 1])
+
+ expect do
+ subject.dump
+ end.to output(/rsync failed/).to_stdout
+ .and raise_error(/Backup failed/)
+ end
+ end
+ end
+
+ describe '#exclude_dirs' do
+ subject { described_class.new('pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) }
+
+ it 'prepends a leading dot slash to tar excludes' do
+ expect(subject.exclude_dirs(:tar)).to eq(['--exclude=lost+found', '--exclude=./@pages.tmp'])
+ end
+
+ it 'prepends a leading slash to rsync excludes' do
+ expect(subject.exclude_dirs(:rsync)).to eq(['--exclude=lost+found', '--exclude=/@pages.tmp'])
+ end
+ end
+
+ describe '#run_pipeline!' do
+ subject { described_class.new('registry', '/var/gitlab-registry') }
+
+ it 'executes an Open3.pipeline for cmd_list' do
+ expect(Open3).to receive(:pipeline).with(%w[whew command], %w[another cmd], any_args)
+
+ subject.run_pipeline!([%w[whew command], %w[another cmd]])
+ end
+
+ it 'returns an empty output on success pipeline' do
+ expect(subject.run_pipeline!(%w[true true])[1]).to eq('')
+ end
+
+ it 'returns the stderr for failed pipeline' do
+ expect(
+ subject.run_pipeline!(['echo OMG: failed command present 1>&2; false', 'true'])[1]
+ ).to match(/OMG: failed/)
+ end
+
+ it 'returns the success status list on success pipeline' do
+ expect(
+ subject.run_pipeline!(%w[true true])[0]
+ ).to eq_statuslist([status_0, status_0])
+ end
+
+ it 'returns the failed status in status list for failed commands in pipeline' do
+ expect(subject.run_pipeline!(%w[false true true])[0]).to eq_statuslist([status_1, status_0, status_0])
+ expect(subject.run_pipeline!(%w[true false true])[0]).to eq_statuslist([status_0, status_1, status_0])
+ expect(subject.run_pipeline!(%w[false false true])[0]).to eq_statuslist([status_1, status_1, status_0])
+ expect(subject.run_pipeline!(%w[false true false])[0]).to eq_statuslist([status_1, status_0, status_1])
+ expect(subject.run_pipeline!(%w[false false false])[0]).to eq_statuslist([status_1, status_1, status_1])
+ end
+ end
+
+ describe '#pipeline_succeeded?' do
+ subject { described_class.new('registry', '/var/gitlab-registry') }
+
+ it 'returns true if both tar and gzip succeeeded' do
+ expect(
+ subject.pipeline_succeeded?(tar_status: status_0, gzip_status: status_0, output: 'any_output')
+ ).to be_truthy
+ end
+
+ it 'returns false if gzip failed' do
+ expect(
+ subject.pipeline_succeeded?(tar_status: status_1, gzip_status: status_1, output: 'any_output')
+ ).to be_falsey
+ end
+
+ context 'if gzip succeeded and tar failed non-critically' do
+ before do
+ allow(subject).to receive(:tar_ignore_non_success?).and_return(true)
+ end
+
+ it 'returns true' do
+ expect(
+ subject.pipeline_succeeded?(tar_status: status_1, gzip_status: status_0, output: 'any_output')
+ ).to be_truthy
+ end
+ end
+
+ context 'if gzip succeeded and tar failed in other cases' do
+ before do
+ allow(subject).to receive(:tar_ignore_non_success?).and_return(false)
+ end
+
+ it 'returns false' do
+ expect(
+ subject.pipeline_succeeded?(tar_status: status_1, gzip_status: status_0, output: 'any_output')
+ ).to be_falsey
+ end
+ end
+ end
+
+ describe '#tar_ignore_non_success?' do
+ subject { described_class.new('registry', '/var/gitlab-registry') }
+
+ context 'if `tar` command exits with 1 exitstatus' do
+ it 'returns true' do
+ expect(
+ subject.tar_ignore_non_success?(1, 'any_output')
+ ).to be_truthy
+ end
+
+ it 'outputs a warning' do
+ expect do
+ subject.tar_ignore_non_success?(1, 'any_output')
+ end.to output(/Ignoring tar exit status 1/).to_stdout
+ end
+ end
+
+ context 'if `tar` command exits with 2 exitstatus with non-critical warning' do
+ before do
+ allow(subject).to receive(:noncritical_warning?).and_return(true)
+ end
+
+ it 'returns true' do
+ expect(
+ subject.tar_ignore_non_success?(2, 'any_output')
+ ).to be_truthy
+ end
+
+ it 'outputs a warning' do
+ expect do
+ subject.tar_ignore_non_success?(2, 'any_output')
+ end.to output(/Ignoring non-success exit status/).to_stdout
+ end
end
- describe '#exclude_dirs' do
- it 'prepends a leading dot slash to tar excludes' do
- expect(subject.exclude_dirs(:tar)).to eq(['--exclude=lost+found', '--exclude=./@pages.tmp'])
+ context 'if `tar` command exits with any other unlisted error' do
+ before do
+ allow(subject).to receive(:noncritical_warning?).and_return(false)
end
- it 'prepends a leading slash to rsync excludes' do
- expect(subject.exclude_dirs(:rsync)).to eq(['--exclude=lost+found', '--exclude=/@pages.tmp'])
+ it 'returns false' do
+ expect(
+ subject.tar_ignore_non_success?(2, 'any_output')
+ ).to be_falsey
end
end
end
+
+ describe '#noncritical_warning?' do
+ subject { described_class.new('registry', '/var/gitlab-registry') }
+
+ it 'returns true if given text matches noncritical warnings list' do
+ expect(
+ subject.noncritical_warning?('tar: .: Cannot mkdir: No such file or directory')
+ ).to be_truthy
+
+ expect(
+ subject.noncritical_warning?('gtar: .: Cannot mkdir: No such file or directory')
+ ).to be_truthy
+ end
+
+ it 'returns false otherwize' do
+ expect(
+ subject.noncritical_warning?('unknown message')
+ ).to be_falsey
+ end
+ end
end
diff --git a/spec/lib/backup/pages_spec.rb b/spec/lib/backup/pages_spec.rb
index 59df4d1adf7..551d2df8f30 100644
--- a/spec/lib/backup/pages_spec.rb
+++ b/spec/lib/backup/pages_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe Backup::Pages do
allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' }
expect(subject).to receive(:tar).and_return('blabla-tar')
- expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args)
+ expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
+ expect(subject).to receive(:pipeline_succeeded?).and_return(true)
subject.dump
end
end
diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb
index 678b670db34..a82cb764f4d 100644
--- a/spec/lib/backup/uploads_spec.rb
+++ b/spec/lib/backup/uploads_spec.rb
@@ -32,7 +32,8 @@ RSpec.describe Backup::Uploads do
it 'excludes tmp from backup tar' do
expect(backup).to receive(:tar).and_return('blabla-tar')
- expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/uploads -cf - .), 'gzip -c -1'], any_args)
+ expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./tmp -C /var/uploads -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], ''])
+ expect(backup).to receive(:pipeline_succeeded?).and_return(true)
backup.dump
end
end
diff --git a/spec/lib/banzai/filter/emoji_filter_spec.rb b/spec/lib/banzai/filter/emoji_filter_spec.rb
index d78763b6939..9005b4401b7 100644
--- a/spec/lib/banzai/filter/emoji_filter_spec.rb
+++ b/spec/lib/banzai/filter/emoji_filter_spec.rb
@@ -21,6 +21,20 @@ RSpec.describe Banzai::Filter::EmojiFilter do
expect(doc.to_html).to match Regexp.escape(exp)
end
+ it 'ignores unicode versions of trademark, copyright, and registered trademark' do
+ exp = act = '<p>™ © ®</p>'
+ doc = filter(act)
+ expect(doc.to_html).to match Regexp.escape(exp)
+ end
+
+ it 'replaces name versions of trademark, copyright, and registered trademark' do
+ doc = filter('<p>:tm: :copyright: :registered:</p>')
+
+ expect(doc.css('gl-emoji')[0].text).to eq '™'
+ expect(doc.css('gl-emoji')[1].text).to eq '©'
+ expect(doc.css('gl-emoji')[2].text).to eq '®'
+ end
+
it 'correctly encodes the URL' do
doc = filter('<p>:+1:</p>')
expect(doc.css('gl-emoji').first.text).to eq '👍'
diff --git a/spec/lib/banzai/filter/normalize_source_filter_spec.rb b/spec/lib/banzai/filter/normalize_source_filter_spec.rb
new file mode 100644
index 00000000000..8eaeec0e7b0
--- /dev/null
+++ b/spec/lib/banzai/filter/normalize_source_filter_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Filter::NormalizeSourceFilter do
+ include FilterSpecHelper
+
+ it 'removes the UTF8 BOM from the beginning of the text' do
+ content = "\xEF\xBB\xBF---"
+
+ output = filter(content)
+
+ expect(output).to match '---'
+ end
+
+ it 'does not remove those characters from anywhere else in the text' do
+ content = <<~MD
+ \xEF\xBB\xBF---
+ \xEF\xBB\xBF---
+ MD
+
+ output = filter(content)
+
+ expect(output).to match "---\n\xEF\xBB\xBF---\n"
+ end
+end
diff --git a/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb b/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb
new file mode 100644
index 00000000000..fc74c592867
--- /dev/null
+++ b/spec/lib/banzai/pipeline/pre_process_pipeline_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Banzai::Pipeline::PreProcessPipeline do
+ it 'pre-processes the source text' do
+ markdown = <<~MD
+ \xEF\xBB\xBF---
+ foo: :foo_symbol
+ bar: :bar_symbol
+ ---
+
+ >>>
+ blockquote
+ >>>
+ MD
+
+ result = described_class.call(markdown, {})
+
+ aggregate_failures do
+ expect(result[:output]).not_to include "\xEF\xBB\xBF"
+ expect(result[:output]).not_to include '---'
+ expect(result[:output]).to include "```yaml\nfoo: :foo_symbol\n"
+ expect(result[:output]).to include "> blockquote\n"
+ end
+ end
+end
diff --git a/spec/lib/banzai/reference_parser/base_parser_spec.rb b/spec/lib/banzai/reference_parser/base_parser_spec.rb
index 5ab76b2c68b..18d8418ca23 100644
--- a/spec/lib/banzai/reference_parser/base_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/base_parser_spec.rb
@@ -323,6 +323,9 @@ RSpec.describe Banzai::ReferenceParser::BaseParser do
it 'will not overflow the stack' do
ids = 1.upto(1_000_000).to_a
+ # Avoid executing a large, unnecessary SQL query
+ expect(User).to receive(:where).with(id: ids).and_return(User.none)
+
expect { subject.collection_objects_for_ids(User, ids) }.not_to raise_error
end
end
diff --git a/spec/lib/banzai/reference_parser/design_parser_spec.rb b/spec/lib/banzai/reference_parser/design_parser_spec.rb
index 92d3a4aaad2..a9cb2952c26 100644
--- a/spec/lib/banzai/reference_parser/design_parser_spec.rb
+++ b/spec/lib/banzai/reference_parser/design_parser_spec.rb
@@ -29,9 +29,11 @@ RSpec.describe Banzai::ReferenceParser::DesignParser do
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
diff --git a/spec/lib/bitbucket_server/client_spec.rb b/spec/lib/bitbucket_server/client_spec.rb
index 9dcd1500aab..cd3179f19d4 100644
--- a/spec/lib/bitbucket_server/client_spec.rb
+++ b/spec/lib/bitbucket_server/client_spec.rb
@@ -19,6 +19,15 @@ RSpec.describe BitbucketServer::Client do
subject.pull_requests(project, repo_slug)
end
+
+ it 'requests a collection with offset and limit' do
+ offset = 10
+ limit = 100
+
+ expect(BitbucketServer::Paginator).to receive(:new).with(anything, path, :pull_request, page_offset: offset, limit: limit)
+
+ subject.pull_requests(project, repo_slug, page_offset: offset, limit: limit)
+ end
end
describe '#activities' do
diff --git a/spec/lib/gitlab/bulk_import/client_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb
index a6f8dd6d194..2d841b7fac2 100644
--- a/spec/lib/gitlab/bulk_import/client_spec.rb
+++ b/spec/lib/bulk_imports/clients/http_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::BulkImport::Client do
+RSpec.describe BulkImports::Clients::Http do
include ImportSpecHelper
let(:uri) { 'http://gitlab.example' }
@@ -22,16 +22,6 @@ RSpec.describe Gitlab::BulkImport::Client do
end
end
- describe 'parsed response' do
- it 'returns parsed response' do
- response_double = double(code: 200, success?: true, parsed_response: [{ id: 1 }, { id: 2 }])
-
- allow(Gitlab::HTTP).to receive(:get).and_return(response_double)
-
- expect(subject.get(resource)).to eq(response_double.parsed_response)
- end
- end
-
describe 'request query' do
include_examples 'performs network request' do
let(:expected_args) do
@@ -91,5 +81,52 @@ RSpec.describe Gitlab::BulkImport::Client do
end
end
end
+
+ describe '#each_page' do
+ let(:objects1) { [{ object: 1 }, { object: 2 }] }
+ let(:objects2) { [{ object: 3 }, { object: 4 }] }
+ let(:response1) { double(success?: true, headers: { 'x-next-page' => 2 }, parsed_response: objects1) }
+ let(:response2) { double(success?: true, headers: {}, parsed_response: objects2) }
+
+ before do
+ stub_http_get('groups', { page: 1, per_page: 30 }, response1)
+ stub_http_get('groups', { page: 2, per_page: 30 }, response2)
+ end
+
+ context 'with a block' do
+ it 'yields every retrieved page to the supplied block' do
+ pages = []
+
+ subject.each_page(:get, 'groups') { |page| pages << page }
+
+ expect(pages[0]).to be_an_instance_of(Array)
+ expect(pages[1]).to be_an_instance_of(Array)
+
+ expect(pages[0]).to eq(objects1)
+ expect(pages[1]).to eq(objects2)
+ end
+ end
+
+ context 'without a block' do
+ it 'returns an Enumerator' do
+ expect(subject.each_page(:get, :foo)).to be_an_instance_of(Enumerator)
+ end
+ end
+
+ private
+
+ def stub_http_get(path, query, response)
+ uri = "http://gitlab.example:80/api/v4/#{path}"
+ params = {
+ follow_redirects: false,
+ headers: {
+ "Authorization" => "Bearer token",
+ "Content-Type" => "application/json"
+ }
+ }.merge(query: query)
+
+ allow(Gitlab::HTTP).to receive(:get).with(uri, params).and_return(response)
+ end
+ end
end
end
diff --git a/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb
new file mode 100644
index 00000000000..cde8e2d5c18
--- /dev/null
+++ b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Common::Extractors::GraphqlExtractor do
+ let(:graphql_client) { instance_double(BulkImports::Clients::Graphql) }
+ let(:import_entity) { create(:bulk_import_entity) }
+ let(:response) { double(original_hash: { foo: :bar }) }
+ let(:query) { { query: double(to_s: 'test', variables: {}) } }
+ let(:context) do
+ instance_double(
+ BulkImports::Pipeline::Context,
+ entity: import_entity
+ )
+ end
+
+ subject { described_class.new(query) }
+
+ before do
+ allow(subject).to receive(:graphql_client).and_return(graphql_client)
+ allow(graphql_client).to receive(:parse)
+ end
+
+ describe '#extract' do
+ before do
+ allow(subject).to receive(:query_variables).and_return({})
+ allow(graphql_client).to receive(:execute).and_return(response)
+ end
+
+ it 'returns an enumerator with fetched results' do
+ response = subject.extract(context)
+
+ expect(response).to be_instance_of(Enumerator)
+ expect(response.first).to eq({ foo: :bar })
+ end
+ end
+
+ describe 'query variables' do
+ before do
+ allow(graphql_client).to receive(:execute).and_return(response)
+ end
+
+ context 'when variables are present' do
+ let(:query) { { query: double(to_s: 'test', variables: { full_path: :source_full_path }) } }
+
+ it 'builds graphql query variables for import entity' do
+ expected_variables = { full_path: import_entity.source_full_path }
+
+ expect(graphql_client).to receive(:execute).with(anything, expected_variables)
+
+ subject.extract(context).first
+ end
+ end
+
+ context 'when no variables are present' do
+ let(:query) { { query: double(to_s: 'test', variables: nil) } }
+
+ it 'returns empty hash' do
+ expect(graphql_client).to receive(:execute).with(anything, nil)
+
+ subject.extract(context).first
+ end
+ end
+
+ context 'when variables are empty hash' do
+ let(:query) { { query: double(to_s: 'test', variables: {}) } }
+
+ it 'makes graphql request with empty hash' do
+ expect(graphql_client).to receive(:execute).with(anything, {})
+
+ subject.extract(context).first
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb b/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb
new file mode 100644
index 00000000000..4de7d95172f
--- /dev/null
+++ b/spec/lib/bulk_imports/common/loaders/entity_loader_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Common::Loaders::EntityLoader do
+ describe '#load' do
+ it "creates entities for the given data" do
+ group = create(:group, path: "imported-group")
+ parent_entity = create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import))
+ context = instance_double(BulkImports::Pipeline::Context, entity: parent_entity)
+
+ data = {
+ source_type: :group_entity,
+ source_full_path: "parent/subgroup",
+ destination_name: "subgroup",
+ destination_namespace: parent_entity.group.full_path,
+ parent_id: parent_entity.id
+ }
+
+ expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1)
+
+ subgroup_entity = BulkImports::Entity.last
+
+ expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
+ expect(subgroup_entity.destination_namespace).to eq 'imported-group'
+ expect(subgroup_entity.destination_name).to eq 'subgroup'
+ expect(subgroup_entity.parent_id).to eq parent_entity.id
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb
new file mode 100644
index 00000000000..8f39b6e7c93
--- /dev/null
+++ b/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Common::Transformers::GraphqlCleanerTransformer do
+ describe '#transform' do
+ let_it_be(:expected_output) do
+ {
+ 'name' => 'test',
+ 'fullName' => 'test',
+ 'description' => 'test',
+ 'labels' => [
+ { 'title' => 'label1' },
+ { 'title' => 'label2' },
+ { 'title' => 'label3' }
+ ]
+ }
+ end
+
+ it 'deep cleans hash from GraphQL keys' do
+ data = {
+ 'data' => {
+ 'group' => {
+ 'name' => 'test',
+ 'fullName' => 'test',
+ 'description' => 'test',
+ 'labels' => {
+ 'edges' => [
+ { 'node' => { 'title' => 'label1' } },
+ { 'node' => { 'title' => 'label2' } },
+ { 'node' => { 'title' => 'label3' } }
+ ]
+ }
+ }
+ }
+ }
+
+ transformed_data = described_class.new.transform(nil, data)
+
+ expect(transformed_data).to eq(expected_output)
+ end
+
+ context 'when data does not have data/group nesting' do
+ it 'deep cleans hash from GraphQL keys' do
+ data = {
+ 'name' => 'test',
+ 'fullName' => 'test',
+ 'description' => 'test',
+ 'labels' => {
+ 'edges' => [
+ { 'node' => { 'title' => 'label1' } },
+ { 'node' => { 'title' => 'label2' } },
+ { 'node' => { 'title' => 'label3' } }
+ ]
+ }
+ }
+
+ transformed_data = described_class.new.transform(nil, data)
+
+ expect(transformed_data).to eq(expected_output)
+ end
+ end
+
+ context 'when data is not a hash' do
+ it 'does not perform transformation' do
+ data = 'test'
+
+ transformed_data = described_class.new.transform(nil, data)
+
+ expect(transformed_data).to eq(data)
+ end
+ end
+
+ context 'when nested data is not an array or hash' do
+ it 'only removes top level data/group keys' do
+ data = {
+ 'data' => {
+ 'group' => 'test'
+ }
+ }
+
+ transformed_data = described_class.new.transform(nil, data)
+
+ expect(transformed_data).to eq('test')
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb
new file mode 100644
index 00000000000..cdffa750694
--- /dev/null
+++ b/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Common::Transformers::UnderscorifyKeysTransformer do
+ describe '#transform' do
+ it 'deep underscorifies hash keys' do
+ data = {
+ 'fullPath' => 'Foo',
+ 'snakeKeys' => {
+ 'snakeCaseKey' => 'Bar',
+ 'moreKeys' => {
+ 'anotherSnakeCaseKey' => 'Test'
+ }
+ }
+ }
+
+ transformed_data = described_class.new.transform(nil, data)
+
+ expect(transformed_data).to have_key('full_path')
+ expect(transformed_data).to have_key('snake_keys')
+ expect(transformed_data['snake_keys']).to have_key('snake_case_key')
+ expect(transformed_data['snake_keys']).to have_key('more_keys')
+ expect(transformed_data.dig('snake_keys', 'more_keys')).to have_key('another_snake_case_key')
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb
new file mode 100644
index 00000000000..b14dfc615a9
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Loaders::GroupLoader do
+ describe '#load' do
+ let(:user) { create(:user) }
+ let(:data) { { foo: :bar } }
+ let(:service_double) { instance_double(::Groups::CreateService) }
+ let(:entity) { create(:bulk_import_entity) }
+ let(:context) do
+ instance_double(
+ BulkImports::Pipeline::Context,
+ entity: entity,
+ current_user: user
+ )
+ end
+
+ subject { described_class.new }
+
+ context 'when user can create group' do
+ shared_examples 'calls Group Create Service to create a new group' do
+ it 'calls Group Create Service to create a new group' do
+ expect(::Groups::CreateService).to receive(:new).with(context.current_user, data).and_return(service_double)
+ expect(service_double).to receive(:execute)
+ expect(entity).to receive(:update!)
+
+ subject.load(context, data)
+ end
+ end
+
+ context 'when there is no parent group' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :create_group).and_return(true)
+ end
+
+ include_examples 'calls Group Create Service to create a new group'
+ end
+
+ context 'when there is parent group' do
+ let(:parent) { create(:group) }
+ let(:data) { { 'parent_id' => parent.id } }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(true)
+ end
+
+ include_examples 'calls Group Create Service to create a new group'
+ end
+ end
+
+ context 'when user cannot create group' do
+ shared_examples 'does not create new group' do
+ it 'does not create new group' do
+ expect(::Groups::CreateService).not_to receive(:new)
+
+ subject.load(context, data)
+ end
+ end
+
+ context 'when there is no parent group' do
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :create_group).and_return(false)
+ end
+
+ include_examples 'does not create new group'
+ end
+
+ context 'when there is parent group' do
+ let(:parent) { create(:group) }
+ let(:data) { { 'parent_id' => parent.id } }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(false)
+ end
+
+ include_examples 'does not create new group'
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb
new file mode 100644
index 00000000000..3949dd23b49
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb
@@ -0,0 +1,102 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::GroupPipeline do
+ describe '#run' do
+ let(:user) { create(:user) }
+ let(:parent) { create(:group) }
+ let(:entity) do
+ create(
+ :bulk_import_entity,
+ source_full_path: 'source/full/path',
+ destination_name: 'My Destination Group',
+ destination_namespace: parent.full_path
+ )
+ end
+
+ let(:context) do
+ BulkImports::Pipeline::Context.new(
+ current_user: user,
+ entity: entity
+ )
+ end
+
+ let(:group_data) do
+ {
+ 'data' => {
+ 'group' => {
+ 'name' => 'source_name',
+ 'fullPath' => 'source/full/path',
+ 'visibility' => 'private',
+ 'projectCreationLevel' => 'developer',
+ 'subgroupCreationLevel' => 'maintainer',
+ 'description' => 'Group Description',
+ 'emailsDisabled' => true,
+ 'lfsEnabled' => false,
+ 'mentionsDisabled' => true
+ }
+ }
+ }
+ end
+
+ subject { described_class.new }
+
+ before do
+ allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return([group_data])
+ end
+
+ parent.add_owner(user)
+ end
+
+ it 'imports new group into destination group' do
+ group_path = 'my-destination-group'
+
+ subject.run(context)
+
+ imported_group = Group.find_by_path(group_path)
+
+ expect(imported_group).not_to be_nil
+ expect(imported_group.parent).to eq(parent)
+ expect(imported_group.path).to eq(group_path)
+ expect(imported_group.description).to eq(group_data.dig('data', 'group', 'description'))
+ expect(imported_group.visibility).to eq(group_data.dig('data', 'group', 'visibility'))
+ expect(imported_group.project_creation_level).to eq(Gitlab::Access.project_creation_string_options[group_data.dig('data', 'group', 'projectCreationLevel')])
+ expect(imported_group.subgroup_creation_level).to eq(Gitlab::Access.subgroup_creation_string_options[group_data.dig('data', 'group', 'subgroupCreationLevel')])
+ expect(imported_group.lfs_enabled?).to eq(group_data.dig('data', 'group', 'lfsEnabled'))
+ expect(imported_group.emails_disabled?).to eq(group_data.dig('data', 'group', 'emailsDisabled'))
+ expect(imported_group.mentions_disabled?).to eq(group_data.dig('data', 'group', 'mentionsDisabled'))
+ end
+ end
+
+ describe 'pipeline parts' do
+ it { expect(described_class).to include_module(BulkImports::Pipeline) }
+ it { expect(described_class).to include_module(BulkImports::Pipeline::Attributes) }
+ it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
+
+ it 'has extractors' do
+ expect(described_class.extractors)
+ .to contain_exactly(
+ {
+ klass: BulkImports::Common::Extractors::GraphqlExtractor,
+ options: {
+ query: BulkImports::Groups::Graphql::GetGroupQuery
+ }
+ }
+ )
+ end
+
+ it 'has transformers' do
+ expect(described_class.transformers)
+ .to contain_exactly(
+ { klass: BulkImports::Common::Transformers::GraphqlCleanerTransformer, options: nil },
+ { klass: BulkImports::Common::Transformers::UnderscorifyKeysTransformer, options: nil },
+ { klass: BulkImports::Groups::Transformers::GroupAttributesTransformer, options: nil })
+ end
+
+ it 'has loaders' do
+ expect(described_class.loaders).to contain_exactly({ klass: BulkImports::Groups::Loaders::GroupLoader, options: nil })
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
new file mode 100644
index 00000000000..60a4a796682
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do
+ describe '#run' do
+ let_it_be(:user) { create(:user) }
+ let(:parent) { create(:group, name: 'imported-group', path: 'imported-group') }
+ let!(:parent_entity) do
+ create(
+ :bulk_import_entity,
+ destination_namespace: parent.full_path,
+ group: parent
+ )
+ end
+
+ let(:context) do
+ instance_double(
+ BulkImports::Pipeline::Context,
+ current_user: user,
+ entity: parent_entity
+ )
+ end
+
+ let(:subgroup_data) do
+ [
+ {
+ "name" => "subgroup",
+ "full_path" => "parent/subgroup"
+ }
+ ]
+ end
+
+ subject { described_class.new }
+
+ before do
+ allow_next_instance_of(BulkImports::Groups::Extractors::SubgroupsExtractor) do |extractor|
+ allow(extractor).to receive(:extract).and_return(subgroup_data)
+ end
+
+ parent.add_owner(user)
+ end
+
+ it 'creates entities for the subgroups' do
+ expect { subject.run(context) }.to change(BulkImports::Entity, :count).by(1)
+
+ subgroup_entity = BulkImports::Entity.last
+
+ expect(subgroup_entity.source_full_path).to eq 'parent/subgroup'
+ expect(subgroup_entity.destination_namespace).to eq 'imported-group'
+ expect(subgroup_entity.destination_name).to eq 'subgroup'
+ expect(subgroup_entity.parent_id).to eq parent_entity.id
+ end
+ end
+
+ describe 'pipeline parts' do
+ it { expect(described_class).to include_module(BulkImports::Pipeline) }
+ it { expect(described_class).to include_module(BulkImports::Pipeline::Attributes) }
+ it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) }
+
+ it 'has extractors' do
+ expect(described_class.extractors).to contain_exactly(
+ klass: BulkImports::Groups::Extractors::SubgroupsExtractor,
+ options: nil
+ )
+ end
+
+ it 'has transformers' do
+ expect(described_class.transformers).to contain_exactly(
+ klass: BulkImports::Groups::Transformers::SubgroupToEntityTransformer,
+ options: nil
+ )
+ end
+
+ it 'has loaders' do
+ expect(described_class.loaders).to contain_exactly(
+ klass: BulkImports::Common::Loaders::EntityLoader,
+ options: nil
+ )
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
new file mode 100644
index 00000000000..28a7859915d
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do
+ describe '#transform' do
+ let(:user) { create(:user) }
+ let(:parent) { create(:group) }
+ let(:group) { create(:group, name: 'My Source Group', parent: parent) }
+ let(:entity) do
+ instance_double(
+ BulkImports::Entity,
+ source_full_path: 'source/full/path',
+ destination_name: group.name,
+ destination_namespace: parent.full_path
+ )
+ end
+
+ let(:context) do
+ instance_double(
+ BulkImports::Pipeline::Context,
+ current_user: user,
+ entity: entity
+ )
+ end
+
+ let(:data) do
+ {
+ 'name' => 'source_name',
+ 'full_path' => 'source/full/path',
+ 'visibility' => 'private',
+ 'project_creation_level' => 'developer',
+ 'subgroup_creation_level' => 'maintainer'
+ }
+ end
+
+ subject { described_class.new }
+
+ it 'transforms name to destination name' do
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data['name']).not_to eq('source_name')
+ expect(transformed_data['name']).to eq(group.name)
+ end
+
+ it 'removes full path' do
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data).not_to have_key('full_path')
+ end
+
+ it 'transforms path to parameterized name' do
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data['path']).to eq(group.name.parameterize)
+ end
+
+ it 'transforms visibility level' do
+ visibility = data['visibility']
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data).not_to have_key('visibility')
+ expect(transformed_data['visibility_level']).to eq(Gitlab::VisibilityLevel.string_options[visibility])
+ end
+
+ it 'transforms project creation level' do
+ level = data['project_creation_level']
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data['project_creation_level']).to eq(Gitlab::Access.project_creation_string_options[level])
+ end
+
+ it 'transforms subgroup creation level' do
+ level = data['subgroup_creation_level']
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data['subgroup_creation_level']).to eq(Gitlab::Access.subgroup_creation_string_options[level])
+ end
+
+ describe 'parent group transformation' do
+ it 'sets parent id' do
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data['parent_id']).to eq(parent.id)
+ end
+
+ context 'when destination namespace is user namespace' do
+ let(:entity) do
+ instance_double(
+ BulkImports::Entity,
+ source_full_path: 'source/full/path',
+ destination_name: group.name,
+ destination_namespace: user.namespace.full_path
+ )
+ end
+
+ it 'does not set parent id' do
+ transformed_data = subject.transform(context, data)
+
+ expect(transformed_data).not_to have_key('parent_id')
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb
new file mode 100644
index 00000000000..2f97a5721e7
--- /dev/null
+++ b/spec/lib/bulk_imports/groups/transformers/subgroup_to_entity_transformer_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Groups::Transformers::SubgroupToEntityTransformer do
+ describe "#transform" do
+ it "transforms subgroups data in entity params" do
+ parent = create(:group)
+ parent_entity = instance_double(BulkImports::Entity, group: parent, id: 1)
+ context = instance_double(BulkImports::Pipeline::Context, entity: parent_entity)
+ subgroup_data = {
+ "name" => "subgroup",
+ "full_path" => "parent/subgroup"
+ }
+
+ expect(subject.transform(context, subgroup_data)).to eq(
+ source_type: :group_entity,
+ source_full_path: "parent/subgroup",
+ destination_name: "subgroup",
+ destination_namespace: parent.full_path,
+ parent_id: 1
+ )
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb
new file mode 100644
index 00000000000..95ac5925c97
--- /dev/null
+++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Importers::GroupImporter do
+ let(:user) { create(:user) }
+ let(:bulk_import) { create(:bulk_import) }
+ let(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) }
+ let(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) }
+ let(:context) do
+ BulkImports::Pipeline::Context.new(
+ current_user: user,
+ entity: bulk_import_entity,
+ configuration: bulk_import_configuration
+ )
+ end
+
+ subject { described_class.new(bulk_import_entity) }
+
+ before do
+ allow(BulkImports::Pipeline::Context).to receive(:new).and_return(context)
+ stub_http_requests
+ end
+
+ describe '#execute' do
+ it "starts the entity and run its pipelines" do
+ expect(bulk_import_entity).to receive(:start).and_call_original
+ expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context
+ expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context
+
+ subject.execute
+
+ expect(bulk_import_entity.reload).to be_finished
+ end
+ end
+
+ def expect_to_run_pipeline(klass, context:)
+ expect_next_instance_of(klass) do |pipeline|
+ expect(pipeline).to receive(:run).with(context)
+ end
+ end
+
+ def stub_http_requests
+ double_response = double(
+ code: 200,
+ success?: true,
+ parsed_response: {},
+ headers: {}
+ )
+
+ allow_next_instance_of(BulkImports::Clients::Http) do |client|
+ allow(client).to receive(:get).and_return(double_response)
+ allow(client).to receive(:post).and_return(double_response)
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/importers/groups_importer_spec.rb b/spec/lib/bulk_imports/importers/groups_importer_spec.rb
new file mode 100644
index 00000000000..4865034b0cd
--- /dev/null
+++ b/spec/lib/bulk_imports/importers/groups_importer_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Importers::GroupsImporter do
+ let_it_be(:bulk_import) { create(:bulk_import) }
+
+ subject { described_class.new(bulk_import.id) }
+
+ describe '#execute' do
+ context "when there is entities to be imported" do
+ let!(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) }
+
+ it "starts the bulk_import and imports its entities" do
+ expect(BulkImports::Importers::GroupImporter).to receive(:new)
+ .with(bulk_import_entity).and_return(double(execute: true))
+ expect(BulkImportWorker).to receive(:perform_async).with(bulk_import.id)
+
+ subject.execute
+
+ expect(bulk_import.reload).to be_started
+ end
+ end
+
+ context "when there is no entities to be imported" do
+ it "starts the bulk_import and imports its entities" do
+ expect(BulkImports::Importers::GroupImporter).not_to receive(:new)
+ expect(BulkImportWorker).not_to receive(:perform_async)
+
+ subject.execute
+
+ expect(bulk_import.reload).to be_finished
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/pipeline/attributes_spec.rb b/spec/lib/bulk_imports/pipeline/attributes_spec.rb
new file mode 100644
index 00000000000..54c5dbd4cae
--- /dev/null
+++ b/spec/lib/bulk_imports/pipeline/attributes_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Pipeline::Attributes do
+ describe 'pipeline attributes' do
+ before do
+ stub_const('BulkImports::Extractor', Class.new)
+ stub_const('BulkImports::Transformer', Class.new)
+ stub_const('BulkImports::Loader', Class.new)
+
+ klass = Class.new do
+ include BulkImports::Pipeline::Attributes
+
+ extractor BulkImports::Extractor, { foo: :bar }
+ transformer BulkImports::Transformer, { foo: :bar }
+ loader BulkImports::Loader, { foo: :bar }
+ end
+
+ stub_const('BulkImports::MyPipeline', klass)
+ end
+
+ describe 'getters' do
+ it 'retrieves class attributes' do
+ expect(BulkImports::MyPipeline.extractors).to contain_exactly({ klass: BulkImports::Extractor, options: { foo: :bar } })
+ expect(BulkImports::MyPipeline.transformers).to contain_exactly({ klass: BulkImports::Transformer, options: { foo: :bar } })
+ expect(BulkImports::MyPipeline.loaders).to contain_exactly({ klass: BulkImports::Loader, options: { foo: :bar } })
+ end
+ end
+
+ describe 'setters' do
+ it 'sets class attributes' do
+ klass = Class.new
+ options = { test: :test }
+
+ BulkImports::MyPipeline.extractor(klass, options)
+ BulkImports::MyPipeline.transformer(klass, options)
+ BulkImports::MyPipeline.loader(klass, options)
+
+ expect(BulkImports::MyPipeline.extractors)
+ .to contain_exactly(
+ { klass: BulkImports::Extractor, options: { foo: :bar } },
+ { klass: klass, options: options })
+
+ expect(BulkImports::MyPipeline.transformers)
+ .to contain_exactly(
+ { klass: BulkImports::Transformer, options: { foo: :bar } },
+ { klass: klass, options: options })
+
+ expect(BulkImports::MyPipeline.loaders)
+ .to contain_exactly(
+ { klass: BulkImports::Loader, options: { foo: :bar } },
+ { klass: klass, options: options })
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/pipeline/context_spec.rb b/spec/lib/bulk_imports/pipeline/context_spec.rb
new file mode 100644
index 00000000000..e9af6313ca4
--- /dev/null
+++ b/spec/lib/bulk_imports/pipeline/context_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Pipeline::Context do
+ describe '#initialize' do
+ it 'initializes with permitted attributes' do
+ args = {
+ current_user: create(:user),
+ entity: create(:bulk_import_entity),
+ configuration: create(:bulk_import_configuration)
+ }
+
+ context = described_class.new(args)
+
+ args.each do |k, v|
+ expect(context.public_send(k)).to eq(v)
+ end
+ end
+
+ context 'when invalid argument is passed' do
+ it 'raises NoMethodError' do
+ expect { described_class.new(test: 'test').test }.to raise_exception(NoMethodError)
+ end
+ end
+ end
+end
diff --git a/spec/lib/bulk_imports/pipeline/runner_spec.rb b/spec/lib/bulk_imports/pipeline/runner_spec.rb
new file mode 100644
index 00000000000..8c882c799ec
--- /dev/null
+++ b/spec/lib/bulk_imports/pipeline/runner_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Pipeline::Runner do
+ describe 'pipeline runner' do
+ before do
+ extractor = Class.new do
+ def initialize(options = {}); end
+
+ def extract(context); end
+ end
+
+ transformer = Class.new do
+ def initialize(options = {}); end
+
+ def transform(context, entry); end
+ end
+
+ loader = Class.new do
+ def initialize(options = {}); end
+
+ def load(context, entry); end
+ end
+
+ stub_const('BulkImports::Extractor', extractor)
+ stub_const('BulkImports::Transformer', transformer)
+ stub_const('BulkImports::Loader', loader)
+
+ pipeline = Class.new do
+ include BulkImports::Pipeline
+
+ extractor BulkImports::Extractor
+ transformer BulkImports::Transformer
+ loader BulkImports::Loader
+ end
+
+ stub_const('BulkImports::MyPipeline', pipeline)
+ end
+
+ it 'runs pipeline extractor, transformer, loader' do
+ context = instance_double(
+ BulkImports::Pipeline::Context,
+ entity: instance_double(BulkImports::Entity, id: 1, source_type: 'group')
+ )
+ entries = [{ foo: :bar }]
+
+ expect_next_instance_of(BulkImports::Extractor) do |extractor|
+ expect(extractor).to receive(:extract).with(context).and_return(entries)
+ end
+
+ expect_next_instance_of(BulkImports::Transformer) do |transformer|
+ expect(transformer).to receive(:transform).with(context, entries.first).and_return(entries.first)
+ end
+
+ expect_next_instance_of(BulkImports::Loader) do |loader|
+ expect(loader).to receive(:load).with(context, entries.first)
+ end
+
+ expect_next_instance_of(Gitlab::Import::Logger) do |logger|
+ expect(logger).to receive(:info)
+ .with(message: "Pipeline started", pipeline: 'BulkImports::MyPipeline', entity: 1, entity_type: 'group')
+ expect(logger).to receive(:info)
+ .with(entity: 1, entity_type: 'group', extractor: 'BulkImports::Extractor')
+ expect(logger).to receive(:info)
+ .with(entity: 1, entity_type: 'group', transformer: 'BulkImports::Transformer')
+ expect(logger).to receive(:info)
+ .with(entity: 1, entity_type: 'group', loader: 'BulkImports::Loader')
+ end
+
+ BulkImports::MyPipeline.new.run(context)
+ end
+ end
+end
diff --git a/spec/lib/container_registry/client_spec.rb b/spec/lib/container_registry/client_spec.rb
index 4daf7375a40..2c08fdc1e75 100644
--- a/spec/lib/container_registry/client_spec.rb
+++ b/spec/lib/container_registry/client_spec.rb
@@ -3,9 +3,12 @@
require 'spec_helper'
RSpec.describe ContainerRegistry::Client do
+ using RSpec::Parameterized::TableSyntax
+
let(:token) { '12345' }
let(:options) { { token: token } }
- let(:client) { described_class.new("http://container-registry", options) }
+ let(:registry_api_url) { 'http://container-registry' }
+ let(:client) { described_class.new(registry_api_url, options) }
let(:push_blob_headers) do
{
'Accept' => 'application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.manifest.v1+json',
@@ -101,16 +104,6 @@ RSpec.describe ContainerRegistry::Client do
end
end
- def stub_upload(path, content, digest, status = 200)
- stub_request(:post, "http://container-registry/v2/#{path}/blobs/uploads/")
- .with(headers: headers_with_accept_types)
- .to_return(status: status, body: "", headers: { 'location' => 'http://container-registry/next_upload?id=someid' })
-
- stub_request(:put, "http://container-registry/next_upload?digest=#{digest}&id=someid")
- .with(body: content, headers: push_blob_headers)
- .to_return(status: status, body: "", headers: {})
- end
-
describe '#upload_blob' do
subject { client.upload_blob('path', 'content', 'sha256:123') }
@@ -221,28 +214,36 @@ RSpec.describe ContainerRegistry::Client do
describe '#supports_tag_delete?' do
subject { client.supports_tag_delete? }
- context 'when the server supports tag deletion' do
- before do
- stub_request(:options, "http://container-registry/v2/name/tags/reference/tag")
- .to_return(status: 200, body: "", headers: { 'Allow' => 'DELETE' })
- end
-
- it { is_expected.to be_truthy }
+ where(:registry_tags_support_enabled, :is_on_dot_com, :container_registry_features, :expect_registry_to_be_pinged, :expected_result) do
+ true | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | true
+ true | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | true | true
+ true | true | [] | true | true
+ true | false | [] | true | true
+ false | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | true
+ false | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | true | false
+ false | true | [] | true | false
+ false | false | [] | true | false
end
- context 'when the server does not support tag deletion' do
+ with_them do
before do
- stub_request(:options, "http://container-registry/v2/name/tags/reference/tag")
- .to_return(status: 404, body: "")
+ allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
+ stub_registry_tags_support(registry_tags_support_enabled)
+ stub_application_setting(container_registry_features: container_registry_features)
end
- it { is_expected.to be_falsey }
- end
- end
+ it 'returns the expected result' do
+ if expect_registry_to_be_pinged
+ expect_next_instance_of(Faraday::Connection) do |connection|
+ expect(connection).to receive(:run_request).and_call_original
+ end
+ else
+ expect(Faraday::Connection).not_to receive(:new)
+ end
- def stub_registry_info(headers: {}, status: 200)
- stub_request(:get, 'http://container-registry/v2/')
- .to_return(status: status, body: "", headers: headers)
+ expect(subject).to be expected_result
+ end
+ end
end
describe '#registry_info' do
@@ -291,55 +292,87 @@ RSpec.describe ContainerRegistry::Client do
end
describe '.supports_tag_delete?' do
- let(:registry_enabled) { true }
- let(:registry_api_url) { 'http://sandbox.local' }
- let(:registry_tags_support_enabled) { true }
- let(:is_on_dot_com) { false }
-
subject { described_class.supports_tag_delete? }
- before do
- allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
- stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
- stub_registry_tags_support(registry_tags_support_enabled)
+ where(:registry_api_url, :registry_enabled, :registry_tags_support_enabled, :is_on_dot_com, :container_registry_features, :expect_registry_to_be_pinged, :expected_result) do
+ 'http://sandbox.local' | true | true | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | true
+ 'http://sandbox.local' | true | true | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | true | true
+ 'http://sandbox.local' | true | false | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | true
+ 'http://sandbox.local' | true | false | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | true | false
+ 'http://sandbox.local' | false | true | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ 'http://sandbox.local' | false | true | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ 'http://sandbox.local' | false | false | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ 'http://sandbox.local' | false | false | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ 'http://sandbox.local' | true | true | true | [] | true | true
+ 'http://sandbox.local' | true | true | false | [] | true | true
+ 'http://sandbox.local' | true | false | true | [] | true | false
+ 'http://sandbox.local' | true | false | false | [] | true | false
+ 'http://sandbox.local' | false | true | true | [] | false | false
+ 'http://sandbox.local' | false | true | false | [] | false | false
+ 'http://sandbox.local' | false | false | true | [] | false | false
+ 'http://sandbox.local' | false | false | false | [] | false | false
+ '' | true | true | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | true | true | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | true | false | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | true | false | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | false | true | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | false | true | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | false | false | true | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | false | false | false | [ContainerRegistry::Client::REGISTRY_TAG_DELETE_FEATURE] | false | false
+ '' | true | true | true | [] | false | false
+ '' | true | true | false | [] | false | false
+ '' | true | false | true | [] | false | false
+ '' | true | false | false | [] | false | false
+ '' | false | true | true | [] | false | false
+ '' | false | true | false | [] | false | false
+ '' | false | false | true | [] | false | false
+ '' | false | false | false | [] | false | false
end
- context 'with the registry enabled' do
- it { is_expected.to be true }
-
- context 'without an api url' do
- let(:registry_api_url) { '' }
-
- it { is_expected.to be false }
- end
-
- context 'on .com' do
- let(:is_on_dot_com) { true }
-
- it { is_expected.to be true }
+ with_them do
+ before do
+ allow(::Gitlab).to receive(:com?).and_return(is_on_dot_com)
+ stub_container_registry_config(enabled: registry_enabled, api_url: registry_api_url, key: 'spec/fixtures/x509_certificate_pk.key')
+ stub_registry_tags_support(registry_tags_support_enabled)
+ stub_application_setting(container_registry_features: container_registry_features)
end
- context 'when registry server does not support tag deletion' do
- let(:registry_tags_support_enabled) { false }
+ it 'returns the expected result' do
+ if expect_registry_to_be_pinged
+ expect_next_instance_of(Faraday::Connection) do |connection|
+ expect(connection).to receive(:run_request).and_call_original
+ end
+ else
+ expect(Faraday::Connection).not_to receive(:new)
+ end
- it { is_expected.to be false }
+ expect(subject).to be expected_result
end
end
+ end
+
+ def stub_upload(path, content, digest, status = 200)
+ stub_request(:post, "#{registry_api_url}/v2/#{path}/blobs/uploads/")
+ .with(headers: headers_with_accept_types)
+ .to_return(status: status, body: "", headers: { 'location' => "#{registry_api_url}/next_upload?id=someid" })
- context 'with the registry disabled' do
- let(:registry_enabled) { false }
+ stub_request(:put, "#{registry_api_url}/next_upload?digest=#{digest}&id=someid")
+ .with(body: content, headers: push_blob_headers)
+ .to_return(status: status, body: "", headers: {})
+ end
- it { is_expected.to be false }
- end
+ def stub_registry_info(headers: {}, status: 200)
+ stub_request(:get, "#{registry_api_url}/v2/")
+ .to_return(status: status, body: "", headers: headers)
+ end
- def stub_registry_tags_support(supported = true)
- status_code = supported ? 200 : 404
- stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
- .to_return(
- status: status_code,
- body: '',
- headers: { 'Allow' => 'DELETE' }
- )
- end
+ def stub_registry_tags_support(supported = true)
+ status_code = supported ? 200 : 404
+ stub_request(:options, "#{registry_api_url}/v2/name/tags/reference/tag")
+ .to_return(
+ status: status_code,
+ body: '',
+ headers: { 'Allow' => 'DELETE' }
+ )
end
end
diff --git a/spec/lib/csv_builders/stream_spec.rb b/spec/lib/csv_builders/stream_spec.rb
new file mode 100644
index 00000000000..204baf965d0
--- /dev/null
+++ b/spec/lib/csv_builders/stream_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe CsvBuilders::Stream do
+ let(:event_1) { double(title: 'Added salt', description: 'A teaspoon') }
+ let(:event_2) { double(title: 'Added sugar', description: 'Just a pinch') }
+ let(:fake_relation) { FakeRelation.new([event_1, event_2]) }
+
+ subject(:builder) { described_class.new(fake_relation, 'Title' => 'title', 'Description' => 'description') }
+
+ describe '#render' do
+ before do
+ stub_const('FakeRelation', Array)
+
+ FakeRelation.class_eval do
+ def find_each(&block)
+ each(&block)
+ end
+ end
+ end
+
+ it 'returns a lazy enumerator' do
+ expect(builder.render).to be_an(Enumerator::Lazy)
+ end
+
+ it 'returns all rows up to default max value' do
+ expect(builder.render.to_a).to eq([
+ "Title,Description\n",
+ "Added salt,A teaspoon\n",
+ "Added sugar,Just a pinch\n"
+ ])
+ end
+
+ it 'truncates to max rows' do
+ expect(builder.render(1).to_a).to eq([
+ "Title,Description\n",
+ "Added salt,A teaspoon\n"
+ ])
+ end
+ end
+end
diff --git a/spec/lib/expand_variables_spec.rb b/spec/lib/expand_variables_spec.rb
index 4a5b70ff248..a994b4b92a6 100644
--- a/spec/lib/expand_variables_spec.rb
+++ b/spec/lib/expand_variables_spec.rb
@@ -3,106 +3,132 @@
require 'spec_helper'
RSpec.describe ExpandVariables do
+ shared_examples 'common variable expansion' do |expander|
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "no expansion": {
+ value: 'key',
+ result: 'key',
+ variables: []
+ },
+ "simple expansion": {
+ value: 'key$variable',
+ result: 'keyvalue',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
+ },
+ "simple with hash of variables": {
+ value: 'key$variable',
+ result: 'keyvalue',
+ variables: {
+ 'variable' => 'value'
+ }
+ },
+ "complex expansion": {
+ value: 'key${variable}',
+ result: 'keyvalue',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
+ },
+ "simple expansions": {
+ value: 'key$variable$variable2',
+ result: 'keyvalueresult',
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' }
+ ]
+ },
+ "complex expansions": {
+ value: 'key${variable}${variable2}',
+ result: 'keyvalueresult',
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' }
+ ]
+ },
+ "out-of-order expansion": {
+ value: 'key$variable2$variable',
+ result: 'keyresultvalue',
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' }
+ ]
+ },
+ "out-of-order complex expansion": {
+ value: 'key${variable2}${variable}',
+ result: 'keyresultvalue',
+ variables: [
+ { key: 'variable', value: 'value' },
+ { key: 'variable2', value: 'result' }
+ ]
+ },
+ "review-apps expansion": {
+ value: 'review/$CI_COMMIT_REF_NAME',
+ result: 'review/feature/add-review-apps',
+ variables: [
+ { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' }
+ ]
+ },
+ "do not lazily access variables when no expansion": {
+ value: 'key',
+ result: 'key',
+ variables: -> { raise NotImplementedError }
+ },
+ "lazily access variables": {
+ value: 'key$variable',
+ result: 'keyvalue',
+ variables: -> { [{ key: 'variable', value: 'value' }] }
+ }
+ }
+ end
+
+ with_them do
+ subject { expander.call(value, variables) }
+
+ it { is_expected.to eq(result) }
+ end
+ end
+
describe '#expand' do
context 'table tests' do
- using RSpec::Parameterized::TableSyntax
-
- where do
- {
- "no expansion": {
- value: 'key',
- result: 'key',
- variables: []
- },
- "missing variable": {
- value: 'key$variable',
- result: 'key',
- variables: []
- },
- "simple expansion": {
- value: 'key$variable',
- result: 'keyvalue',
- variables: [
- { key: 'variable', value: 'value' }
- ]
- },
- "simple with hash of variables": {
- value: 'key$variable',
- result: 'keyvalue',
- variables: {
- 'variable' => 'value'
+ it_behaves_like 'common variable expansion', described_class.method(:expand)
+
+ context 'with missing variables' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "missing variable": {
+ value: 'key$variable',
+ result: 'key',
+ variables: []
+ },
+ "complex expansions with missing variable": {
+ value: 'key${variable}${variable2}',
+ result: 'keyvalue',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
+ },
+ "complex expansions with missing variable for Windows": {
+ value: 'key%variable%%variable2%',
+ result: 'keyvalue',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
}
- },
- "complex expansion": {
- value: 'key${variable}',
- result: 'keyvalue',
- variables: [
- { key: 'variable', value: 'value' }
- ]
- },
- "simple expansions": {
- value: 'key$variable$variable2',
- result: 'keyvalueresult',
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' }
- ]
- },
- "complex expansions": {
- value: 'key${variable}${variable2}',
- result: 'keyvalueresult',
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' }
- ]
- },
- "complex expansions with missing variable": {
- value: 'key${variable}${variable2}',
- result: 'keyvalue',
- variables: [
- { key: 'variable', value: 'value' }
- ]
- },
- "out-of-order expansion": {
- value: 'key$variable2$variable',
- result: 'keyresultvalue',
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' }
- ]
- },
- "out-of-order complex expansion": {
- value: 'key${variable2}${variable}',
- result: 'keyresultvalue',
- variables: [
- { key: 'variable', value: 'value' },
- { key: 'variable2', value: 'result' }
- ]
- },
- "review-apps expansion": {
- value: 'review/$CI_COMMIT_REF_NAME',
- result: 'review/feature/add-review-apps',
- variables: [
- { key: 'CI_COMMIT_REF_NAME', value: 'feature/add-review-apps' }
- ]
- },
- "do not lazily access variables when no expansion": {
- value: 'key',
- result: 'key',
- variables: -> { raise NotImplementedError }
- },
- "lazily access variables": {
- value: 'key$variable',
- result: 'keyvalue',
- variables: -> { [{ key: 'variable', value: 'value' }] }
}
- }
- end
+ end
- with_them do
- subject { ExpandVariables.expand(value, variables) }
+ with_them do
+ subject { ExpandVariables.expand(value, variables) }
- it { is_expected.to eq(result) }
+ it { is_expected.to eq(result) }
+ end
end
end
@@ -132,4 +158,70 @@ RSpec.describe ExpandVariables do
end
end
end
+
+ describe '#expand_existing' do
+ context 'table tests' do
+ it_behaves_like 'common variable expansion', described_class.method(:expand_existing)
+
+ context 'with missing variables' do
+ using RSpec::Parameterized::TableSyntax
+
+ where do
+ {
+ "missing variable": {
+ value: 'key$variable',
+ result: 'key$variable',
+ variables: []
+ },
+ "complex expansions with missing variable": {
+ value: 'key${variable}${variable2}',
+ result: 'keyvalue${variable2}',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
+ },
+ "complex expansions with missing variable for Windows": {
+ value: 'key%variable%%variable2%',
+ result: 'keyvalue%variable2%',
+ variables: [
+ { key: 'variable', value: 'value' }
+ ]
+ }
+ }
+ end
+
+ with_them do
+ subject { ExpandVariables.expand_existing(value, variables) }
+
+ it { is_expected.to eq(result) }
+ end
+ end
+ end
+
+ context 'lazily inits variables' do
+ let(:variables) { -> { [{ key: 'variable', value: 'result' }] } }
+
+ subject { described_class.expand_existing(value, variables) }
+
+ context 'when expanding variable' do
+ let(:value) { 'key$variable$variable2' }
+
+ it 'calls block at most once' do
+ expect(variables).to receive(:call).once.and_call_original
+
+ is_expected.to eq('keyresult$variable2')
+ end
+ end
+
+ context 'when no expansion is needed' do
+ let(:value) { 'key' }
+
+ it 'does not call block' do
+ expect(variables).not_to receive(:call)
+
+ is_expected.to eq('key')
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb
index dbb3aa8797e..b69cbbf0ec0 100644
--- a/spec/lib/extracts_path_spec.rb
+++ b/spec/lib/extracts_path_spec.rb
@@ -112,6 +112,19 @@ RSpec.describe ExtractsPath do
end
end
end
+
+ context 'ref and path are nil' do
+ let(:params) { { path: nil, ref: nil } }
+
+ it 'does not set commit' do
+ expect(container.repository).not_to receive(:commit).with('')
+ expect(self).to receive(:render_404)
+
+ assign_ref_vars
+
+ expect(@commit).to be_nil
+ end
+ end
end
it_behaves_like 'extracts refs'
diff --git a/spec/lib/extracts_ref_spec.rb b/spec/lib/extracts_ref_spec.rb
index ca2f1fd7dc1..5433a512981 100644
--- a/spec/lib/extracts_ref_spec.rb
+++ b/spec/lib/extracts_ref_spec.rb
@@ -18,6 +18,21 @@ RSpec.describe ExtractsRef do
allow_any_instance_of(described_class).to receive(:repository_container).and_return(container)
end
- it_behaves_like 'assigns ref vars'
+ describe '#assign_ref_vars' do
+ it_behaves_like 'assigns ref vars'
+
+ context 'ref and path are nil' do
+ let(:params) { { path: nil, ref: nil } }
+
+ it 'does not set commit' do
+ expect(container.repository).not_to receive(:commit).with('')
+
+ assign_ref_vars
+
+ expect(@commit).to be_nil
+ end
+ end
+ end
+
it_behaves_like 'extracts refs'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb
index fe390289ef6..52e9f2d9846 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/code_stage_start_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::CodeStageStart do
let(:subject) { described_class.new({}) }
let(:project) { create(:project) }
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
it 'needs connection with an issue via merge_requests_closing_issues table' do
issue = create(:issue, project: project)
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::CodeStageStart do
other_merge_request = create(:merge_request, source_project: project, source_branch: 'a', target_branch: 'master')
- records = subject.apply_query_customization(MergeRequest.all)
+ records = subject.apply_query_customization(MergeRequest.all).where('merge_requests_closing_issues.issue_id IS NOT NULL')
expect(records).to eq([merge_request])
expect(records).not_to include(other_merge_request)
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb
index 5cc6b05407f..224a18653ed 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_created_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueCreated do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb
index 715ad5a8e7d..bc0e388cf53 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_first_mentioned_in_commit_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueFirstMentionedInCommit do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb
index 56241194f36..ddc5f015a8c 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_stage_end_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueStageEnd do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb
index f3202eab5bb..281cc31c9e0 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_created_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb
index 03b0ccfae43..e1dd2e56e2b 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_first_deployed_to_production_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestFirstDeployedToProduction do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb
index b0c003e6f2a..51324966f26 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_finished_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildFinished do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb
index 8f9aaf6f463..10dcaf23b81 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_last_build_started_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestLastBuildStarted do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb
index f1d2ca9f36e..6e20eb73ed9 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/merge_request_merged_spec.rb
@@ -3,5 +3,5 @@
require 'spec_helper'
RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged do
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
end
diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb
index 3248af524bd..b8c68003127 100644
--- a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb
+++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/plan_stage_start_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::PlanStageStart do
let(:subject) { described_class.new({}) }
let(:project) { create(:project) }
- it_behaves_like 'cycle analytics event'
+ it_behaves_like 'value stream analytics event'
it 'filters issues where first_associated_with_milestone_at or first_added_to_board_at is filled' do
issue1 = create(:issue, project: project)
diff --git a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb b/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
index d232e509e00..115c8145f59 100644
--- a/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
+++ b/spec/lib/gitlab/analytics/instance_statistics/workers_argument_builder_spec.rb
@@ -42,5 +42,40 @@ RSpec.describe Gitlab::Analytics::InstanceStatistics::WorkersArgumentBuilder do
])
end
end
+
+ context 'when custom min and max queries are present' do
+ let(:min_id) { User.second.id }
+ let(:max_id) { User.maximum(:id) }
+ let(:users_measurement_identifier) { ::Analytics::InstanceStatistics::Measurement.identifiers.fetch(:users) }
+
+ before do
+ create_list(:user, 2)
+
+ min_max_queries = {
+ ::Analytics::InstanceStatistics::Measurement.identifiers[:users] => {
+ minimum_query: -> { min_id },
+ maximum_query: -> { max_id }
+ }
+ }
+
+ allow(::Analytics::InstanceStatistics::Measurement).to receive(:identifier_min_max_queries) { min_max_queries }
+ end
+
+ subject do
+ described_class.new(measurement_identifiers: [users_measurement_identifier], recorded_at: recorded_at)
+ .execute
+ end
+
+ it 'uses custom min/max for ids' do
+ expect(subject).to eq([
+ [
+ users_measurement_identifier,
+ min_id,
+ max_id,
+ recorded_at
+ ]
+ ])
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/auth/auth_finders_spec.rb b/spec/lib/gitlab/auth/auth_finders_spec.rb
index 2ebde145bfd..3c19ef0bd1b 100644
--- a/spec/lib/gitlab/auth/auth_finders_spec.rb
+++ b/spec/lib/gitlab/auth/auth_finders_spec.rb
@@ -607,6 +607,7 @@ RSpec.describe Gitlab::Auth::AuthFinders do
set_basic_auth_header(username, build.token)
is_expected.to eq user
+ expect(@current_authenticated_job).to eq build
end
it 'raises error with invalid token' do
diff --git a/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb b/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb
new file mode 100644
index 00000000000..4bf59a02a31
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_design_internal_ids_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillDesignInternalIds, :migration, schema: 20201030203854 do
+ subject { described_class.new(designs) }
+
+ let_it_be(:namespaces) { table(:namespaces) }
+ let_it_be(:projects) { table(:projects) }
+ let_it_be(:designs) { table(:design_management_designs) }
+
+ let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
+ let(:project) { projects.create!(namespace_id: namespace.id) }
+ let(:project_2) { projects.create!(namespace_id: namespace.id) }
+
+ def create_design!(proj = project)
+ designs.create!(project_id: proj.id, filename: generate(:filename))
+ end
+
+ def migrate!
+ relation = designs.where(project_id: [project.id, project_2.id]).select(:project_id).distinct
+
+ subject.perform(relation)
+ end
+
+ it 'backfills the iid for designs' do
+ 3.times { create_design! }
+
+ expect do
+ migrate!
+ end.to change { designs.pluck(:iid) }.from(contain_exactly(nil, nil, nil)).to(contain_exactly(1, 2, 3))
+ end
+
+ it 'scopes IIDs and handles range and starting-point correctly' do
+ create_design!.update!(iid: 10)
+ create_design!.update!(iid: 12)
+ create_design!(project_2).update!(iid: 7)
+ project_3 = projects.create!(namespace_id: namespace.id)
+
+ 2.times { create_design! }
+ 2.times { create_design!(project_2) }
+ 2.times { create_design!(project_3) }
+
+ migrate!
+
+ expect(designs.where(project_id: project.id).pluck(:iid)).to contain_exactly(10, 12, 13, 14)
+ expect(designs.where(project_id: project_2.id).pluck(:iid)).to contain_exactly(7, 8, 9)
+ expect(designs.where(project_id: project_3.id).pluck(:iid)).to contain_exactly(nil, nil)
+ end
+
+ it 'updates the internal ID records' do
+ design = create_design!
+ 2.times { create_design! }
+ design.update!(iid: 10)
+ scope = { project_id: project.id }
+ usage = :design_management_designs
+ init = ->(_d, _s) { 0 }
+
+ ::InternalId.track_greatest(design, scope, usage, 10, init)
+
+ migrate!
+
+ next_iid = ::InternalId.generate_next(design, scope, usage, init)
+
+ expect(designs.pluck(:iid)).to contain_exactly(10, 11, 12)
+ expect(design.reload.iid).to eq(10)
+ expect(next_iid).to eq(13)
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb
new file mode 100644
index 00000000000..7fe82420364
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillJiraTrackerDeploymentType2, :migration, schema: 20201028182809 do
+ let_it_be(:jira_service_temp) { described_class::JiraServiceTemp }
+ let_it_be(:jira_tracker_data_temp) { described_class::JiraTrackerDataTemp }
+ let_it_be(:atlassian_host) { 'https://api.atlassian.net' }
+ let_it_be(:mixedcase_host) { 'https://api.AtlassiaN.nEt' }
+ let_it_be(:server_host) { 'https://my.server.net' }
+
+ let(:jira_service) { jira_service_temp.create!(type: 'JiraService', active: true, category: 'issue_tracker') }
+
+ subject { described_class.new }
+
+ def create_tracker_data(options = {})
+ jira_tracker_data_temp.create!({ service_id: jira_service.id }.merge(options))
+ end
+
+ describe '#perform' do
+ context do
+ it 'ignores if deployment already set' do
+ tracker_data = create_tracker_data(url: atlassian_host, deployment_type: 'server')
+
+ expect(subject).not_to receive(:collect_deployment_type)
+
+ subject.perform(tracker_data.id, tracker_data.id)
+
+ expect(tracker_data.reload.deployment_type).to eq 'server'
+ end
+
+ it 'ignores if no url is set' do
+ tracker_data = create_tracker_data(deployment_type: 'unknown')
+
+ expect(subject).to receive(:collect_deployment_type)
+
+ subject.perform(tracker_data.id, tracker_data.id)
+
+ expect(tracker_data.reload.deployment_type).to eq 'unknown'
+ end
+ end
+
+ context 'when tracker is valid' do
+ let!(:tracker_1) { create_tracker_data(url: atlassian_host, deployment_type: 0) }
+ let!(:tracker_2) { create_tracker_data(url: mixedcase_host, deployment_type: 0) }
+ let!(:tracker_3) { create_tracker_data(url: server_host, deployment_type: 0) }
+ let!(:tracker_4) { create_tracker_data(api_url: server_host, deployment_type: 0) }
+ let!(:tracker_nextbatch) { create_tracker_data(api_url: atlassian_host, deployment_type: 0) }
+
+ it 'sets the proper deployment_type', :aggregate_failures do
+ subject.perform(tracker_1.id, tracker_4.id)
+
+ expect(tracker_1.reload.deployment_cloud?).to be_truthy
+ expect(tracker_2.reload.deployment_cloud?).to be_truthy
+ expect(tracker_3.reload.deployment_server?).to be_truthy
+ expect(tracker_4.reload.deployment_server?).to be_truthy
+ expect(tracker_nextbatch.reload.deployment_unknown?).to be_truthy
+ end
+ end
+
+ it_behaves_like 'marks background migration job records' do
+ let(:arguments) { [1, 4] }
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb b/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb
new file mode 100644
index 00000000000..c2daa35703d
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/backfill_merge_request_cleanup_schedules_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::BackfillMergeRequestCleanupSchedules, schema: 20201103110018 do
+ let(:merge_requests) { table(:merge_requests) }
+ let(:cleanup_schedules) { table(:merge_request_cleanup_schedules) }
+ let(:metrics) { table(:merge_request_metrics) }
+
+ let(:namespace) { table(:namespaces).create!(name: 'name', path: 'path') }
+ let(:project) { table(:projects).create!(namespace_id: namespace.id) }
+
+ subject { described_class.new }
+
+ describe '#perform' do
+ let!(:open_mr) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master') }
+
+ let!(:closed_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) }
+ let!(:closed_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) }
+ let!(:closed_mr_1_metrics) { metrics.create!(merge_request_id: closed_mr_1.id, target_project_id: project.id, latest_closed_at: Time.current, created_at: Time.current, updated_at: Time.current) }
+ let!(:closed_mr_2_metrics) { metrics.create!(merge_request_id: closed_mr_2.id, target_project_id: project.id, latest_closed_at: Time.current, created_at: Time.current, updated_at: Time.current) }
+ let!(:closed_mr_2_cleanup_schedule) { cleanup_schedules.create!(merge_request_id: closed_mr_2.id, scheduled_at: Time.current) }
+
+ let!(:merged_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3) }
+ let!(:merged_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3, updated_at: Time.current) }
+ let!(:merged_mr_1_metrics) { metrics.create!(merge_request_id: merged_mr_1.id, target_project_id: project.id, merged_at: Time.current, created_at: Time.current, updated_at: Time.current) }
+
+ let!(:closed_mr_3) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) }
+ let!(:closed_mr_3_metrics) { metrics.create!(merge_request_id: closed_mr_3.id, target_project_id: project.id, latest_closed_at: Time.current, created_at: Time.current, updated_at: Time.current) }
+
+ it 'creates records for all closed and merged merge requests in range' do
+ expect(Gitlab::BackgroundMigration::Logger).to receive(:info).with(
+ message: 'Backfilled merge_request_cleanup_schedules records',
+ count: 3
+ )
+
+ subject.perform(open_mr.id, merged_mr_2.id)
+
+ aggregate_failures do
+ expect(cleanup_schedules.all.pluck(:merge_request_id))
+ .to contain_exactly(closed_mr_1.id, closed_mr_2.id, merged_mr_1.id, merged_mr_2.id)
+ expect(cleanup_schedules.find_by(merge_request_id: closed_mr_1.id).scheduled_at.to_s)
+ .to eq((closed_mr_1_metrics.latest_closed_at + 14.days).to_s)
+ expect(cleanup_schedules.find_by(merge_request_id: closed_mr_2.id).scheduled_at.to_s)
+ .to eq(closed_mr_2_cleanup_schedule.scheduled_at.to_s)
+ expect(cleanup_schedules.find_by(merge_request_id: merged_mr_1.id).scheduled_at.to_s)
+ .to eq((merged_mr_1_metrics.merged_at + 14.days).to_s)
+ expect(cleanup_schedules.find_by(merge_request_id: merged_mr_2.id).scheduled_at.to_s)
+ .to eq((merged_mr_2.updated_at + 14.days).to_s)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb b/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb
index 1637589d272..934ab7e37f8 100644
--- a/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb
+++ b/spec/lib/gitlab/background_migration/legacy_upload_mover_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
# rubocop: disable RSpec/FactoriesInMigrationSpecs
-RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
+RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover, :aggregate_failures do
let(:test_dir) { FileUploader.options['storage_path'] }
let(:filename) { 'image.png' }
@@ -67,27 +67,35 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
end
end
- shared_examples 'migrates the file correctly' do
- before do
+ shared_examples 'migrates the file correctly' do |remote|
+ it 'creates a new upload record correctly, updates the legacy upload note so that it references the file in the markdown, removes the attachment from the note model, removes the file, moves legacy uploads to the correct location, removes the upload record' do
+ expect(File.exist?(legacy_upload.absolute_path)).to be_truthy unless remote
+
described_class.new(legacy_upload).execute
- end
- it 'creates a new uplaod record correctly' do
expect(new_upload.secret).not_to be_nil
- expect(new_upload.path).to end_with("#{new_upload.secret}/image.png")
+ expect(new_upload.path).to end_with("#{new_upload.secret}/#{filename}")
expect(new_upload.model_id).to eq(project.id)
expect(new_upload.model_type).to eq('Project')
expect(new_upload.uploader).to eq('FileUploader')
- end
- it 'updates the legacy upload note so that it references the file in the markdown' do
- expected_path = File.join('/uploads', new_upload.secret, 'image.png')
+ expected_path = File.join('/uploads', new_upload.secret, filename)
expected_markdown = "some note \n ![image](#{expected_path})"
+
expect(note.reload.note).to eq(expected_markdown)
- end
+ expect(note.attachment.file).to be_nil
+
+ if remote
+ expect(bucket.files.get(remote_file[:key])).to be_nil
+ connection = ::Fog::Storage.new(FileUploader.object_store_credentials)
+ expect(connection.get_object('uploads', new_upload.path)[:status]).to eq(200)
+ else
+ expect(File.exist?(legacy_upload.absolute_path)).to be_falsey
+ expected_path = File.join(test_dir, 'uploads', project.disk_path, new_upload.secret, filename)
+ expect(File.exist?(expected_path)).to be_truthy
+ end
- it 'removes the attachment from the note model' do
- expect(note.reload.attachment.file).to be_nil
+ expect { legacy_upload.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -120,23 +128,6 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
end
context 'when the upload is in local storage' do
- shared_examples 'legacy local file' do
- it 'removes the file correctly' do
- expect(File.exist?(legacy_upload.absolute_path)).to be_truthy
-
- described_class.new(legacy_upload).execute
-
- expect(File.exist?(legacy_upload.absolute_path)).to be_falsey
- end
-
- it 'moves legacy uploads to the correct location' do
- described_class.new(legacy_upload).execute
-
- expected_path = File.join(test_dir, 'uploads', project.disk_path, new_upload.secret, filename)
- expect(File.exist?(expected_path)).to be_truthy
- end
- end
-
context 'when the upload file does not exist on the filesystem' do
let(:legacy_upload) { create_upload(note, filename, false) }
@@ -201,15 +192,11 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
path: "uploads/-/system/note/attachment/#{note.id}/#{filename}", model: note, mount_point: nil)
end
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
context 'when the file can be handled correctly' do
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
end
@@ -217,17 +204,13 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
context 'when the file belongs to a legacy project' do
let(:project) { legacy_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
context 'when the file belongs to a hashed project' do
let(:project) { hashed_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
end
@@ -244,17 +227,13 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
context 'when the file belongs to a legacy project' do
let(:project) { legacy_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
context 'when the file belongs to a hashed project' do
let(:project) { hashed_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy local file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', false
end
end
end
@@ -272,23 +251,6 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
stub_uploads_object_storage(FileUploader)
end
- shared_examples 'legacy remote file' do
- it 'removes the file correctly' do
- # expect(bucket.files.get(remote_file[:key])).to be_nil
-
- described_class.new(legacy_upload).execute
-
- expect(bucket.files.get(remote_file[:key])).to be_nil
- end
-
- it 'moves legacy uploads to the correct remote location' do
- described_class.new(legacy_upload).execute
-
- connection = ::Fog::Storage.new(FileUploader.object_store_credentials)
- expect(connection.get_object('uploads', new_upload.path)[:status]).to eq(200)
- end
- end
-
context 'when the upload file does not exist on the filesystem' do
it_behaves_like 'legacy upload deletion'
end
@@ -300,9 +262,7 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
let(:project) { legacy_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy remote file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', true
end
context 'when the file belongs to a hashed project' do
@@ -312,9 +272,7 @@ RSpec.describe Gitlab::BackgroundMigration::LegacyUploadMover do
let(:project) { hashed_project }
- it_behaves_like 'migrates the file correctly'
- it_behaves_like 'legacy remote file'
- it_behaves_like 'legacy upload deletion'
+ it_behaves_like 'migrates the file correctly', true
end
end
end
diff --git a/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb b/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb
new file mode 100644
index 00000000000..c6385340ca3
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_has_vulnerabilities_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateHasVulnerabilities, schema: 20201103192526 do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:project_settings) { table(:project_settings) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:vulnerability_base_params) { { title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, author_id: user.id } }
+
+ let!(:project_1) { projects.create!(namespace_id: namespace.id, name: 'foo_1') }
+ let!(:project_2) { projects.create!(namespace_id: namespace.id, name: 'foo_2') }
+ let!(:project_3) { projects.create!(namespace_id: namespace.id, name: 'foo_3') }
+
+ before do
+ project_settings.create!(project_id: project_1.id)
+ vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_1.id))
+ vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_3.id))
+
+ allow(::Gitlab::BackgroundMigration::Logger).to receive_messages(info: true, error: true)
+ end
+
+ describe '#perform' do
+ it 'sets `has_vulnerabilities` attribute of project_settings' do
+ expect { subject.perform(project_1.id, project_3.id) }.to change { project_settings.count }.from(1).to(2)
+ .and change { project_settings.where(has_vulnerabilities: true).count }.from(0).to(2)
+ end
+
+ it 'writes info log message' do
+ subject.perform(project_1.id, project_3.id)
+
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:info).with(migrator: described_class.name,
+ message: 'Projects has been processed to populate `has_vulnerabilities` information',
+ count: 2)
+ end
+
+ context 'when non-existing project_id is given' do
+ it 'populates only for the existing projects' do
+ expect { subject.perform(project_1.id, 0, project_3.id) }.to change { project_settings.count }.from(1).to(2)
+ .and change { project_settings.where(has_vulnerabilities: true).count }.from(0).to(2)
+ end
+ end
+
+ context 'when an error happens' do
+ before do
+ allow(described_class::ProjectSetting).to receive(:upsert_for).and_raise('foo')
+ end
+
+ it 'writes error log message' do
+ subject.perform(project_1.id, project_3.id)
+
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:error).with(migrator: described_class.name,
+ message: 'foo',
+ project_ids: [project_1.id, project_3.id])
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb b/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb
new file mode 100644
index 00000000000..44c5f3d1381
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_missing_vulnerability_dismissal_information_spec.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation, schema: 20201028160832 do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:findings) { table(:vulnerability_occurrences) }
+ let(:scanners) { table(:vulnerability_scanners) }
+ let(:identifiers) { table(:vulnerability_identifiers) }
+ let(:feedback) { table(:vulnerability_feedback) }
+
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') }
+ let(:vulnerability_1) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let(:vulnerability_2) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let(:scanner) { scanners.create!(project_id: project.id, external_id: 'foo', name: 'bar') }
+ let(:identifier) { identifiers.create!(project_id: project.id, fingerprint: 'foo', external_type: 'bar', external_id: 'zoo', name: 'identifier') }
+
+ before do
+ feedback.create!(feedback_type: 0,
+ category: 'sast',
+ project_fingerprint: '418291a26024a1445b23fe64de9380cdcdfd1fa8',
+ project_id: project.id,
+ author_id: user.id,
+ created_at: Time.current)
+
+ findings.create!(name: 'Finding',
+ report_type: 'sast',
+ project_fingerprint: Gitlab::Database::ShaAttribute.new.serialize('418291a26024a1445b23fe64de9380cdcdfd1fa8'),
+ location_fingerprint: 'bar',
+ severity: 1,
+ confidence: 1,
+ metadata_version: 1,
+ raw_metadata: '',
+ uuid: SecureRandom.uuid,
+ project_id: project.id,
+ vulnerability_id: vulnerability_1.id,
+ scanner_id: scanner.id,
+ primary_identifier_id: identifier.id)
+
+ allow(::Gitlab::BackgroundMigration::Logger).to receive_messages(info: true, warn: true, error: true)
+ end
+
+ describe '#perform' do
+ it 'updates the missing dismissal information of the vulnerability' do
+ expect { subject.perform(vulnerability_1.id, vulnerability_2.id) }.to change { vulnerability_1.reload.dismissed_at }.from(nil)
+ .and change { vulnerability_1.reload.dismissed_by_id }.from(nil).to(user.id)
+ end
+
+ it 'writes log messages' do
+ subject.perform(vulnerability_1.id, vulnerability_2.id)
+
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:info).with(migrator: described_class.name,
+ message: 'Dismissal information has been copied',
+ count: 2)
+ expect(::Gitlab::BackgroundMigration::Logger).to have_received(:warn).with(migrator: described_class.name,
+ message: 'Could not update vulnerability!',
+ vulnerability_id: vulnerability_2.id)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb b/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb
index fa4f2d1fd88..561a602fab9 100644
--- a/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb
+++ b/spec/lib/gitlab/background_migration/replace_blocked_by_links_spec.rb
@@ -9,28 +9,34 @@ RSpec.describe Gitlab::BackgroundMigration::ReplaceBlockedByLinks, schema: 20201
let(:issue2) { table(:issues).create!(project_id: project.id, title: 'b') }
let(:issue3) { table(:issues).create!(project_id: project.id, title: 'c') }
let(:issue_links) { table(:issue_links) }
- let!(:blocks_link) { issue_links.create!(source_id: issue1.id, target_id: issue2.id, link_type: 1) }
- let!(:bidirectional_link) { issue_links.create!(source_id: issue2.id, target_id: issue1.id, link_type: 2) }
- let!(:blocked_link) { issue_links.create!(source_id: issue1.id, target_id: issue3.id, link_type: 2) }
+ let!(:blocked_link1) { issue_links.create!(source_id: issue2.id, target_id: issue1.id, link_type: 2) }
+ let!(:opposite_link1) { issue_links.create!(source_id: issue1.id, target_id: issue2.id, link_type: 1) }
+ let!(:blocked_link2) { issue_links.create!(source_id: issue1.id, target_id: issue3.id, link_type: 2) }
+ let!(:opposite_link2) { issue_links.create!(source_id: issue3.id, target_id: issue1.id, link_type: 0) }
+ let!(:nochange_link) { issue_links.create!(source_id: issue2.id, target_id: issue3.id, link_type: 1) }
subject { described_class.new.perform(issue_links.minimum(:id), issue_links.maximum(:id)) }
- it 'deletes issue links where opposite relation already exists' do
- expect { subject }.to change { issue_links.count }.by(-1)
+ it 'deletes any opposite relations' do
+ subject
+
+ expect(issue_links.ids).to match_array([nochange_link.id, blocked_link1.id, blocked_link2.id])
end
it 'ignores issue links other than blocked_by' do
subject
- expect(blocks_link.reload.link_type).to eq(1)
+ expect(nochange_link.reload.link_type).to eq(1)
end
it 'updates blocked_by issue links' do
subject
- link = blocked_link.reload
- expect(link.link_type).to eq(1)
- expect(link.source_id).to eq(issue3.id)
- expect(link.target_id).to eq(issue1.id)
+ expect(blocked_link1.reload.link_type).to eq(1)
+ expect(blocked_link1.source_id).to eq(issue1.id)
+ expect(blocked_link1.target_id).to eq(issue2.id)
+ expect(blocked_link2.reload.link_type).to eq(1)
+ expect(blocked_link2.source_id).to eq(issue3.id)
+ expect(blocked_link2.target_id).to eq(issue1.id)
end
end
diff --git a/spec/lib/gitlab/badge/coverage/report_spec.rb b/spec/lib/gitlab/badge/coverage/report_spec.rb
index 4a9508712a4..3b5ea3291e4 100644
--- a/spec/lib/gitlab/badge/coverage/report_spec.rb
+++ b/spec/lib/gitlab/badge/coverage/report_spec.rb
@@ -3,13 +3,24 @@
require 'spec_helper'
RSpec.describe Gitlab::Badge::Coverage::Report do
- let(:project) { create(:project, :repository) }
- let(:job_name) { nil }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:success_pipeline) { create(:ci_pipeline, :success, project: project) }
+ let_it_be(:running_pipeline) { create(:ci_pipeline, :running, project: project) }
+ let_it_be(:failure_pipeline) { create(:ci_pipeline, :failed, project: project) }
+
+ let_it_be(:builds) do
+ [
+ create(:ci_build, :success, pipeline: success_pipeline, coverage: 40, created_at: 9.seconds.ago, name: 'coverage'),
+ create(:ci_build, :success, pipeline: success_pipeline, coverage: 60, created_at: 8.seconds.ago)
+ ]
+ end
let(:badge) do
described_class.new(project, 'master', opts: { job: job_name })
end
+ let(:job_name) { nil }
+
describe '#entity' do
it 'describes a coverage' do
expect(badge.entity).to eq 'coverage'
@@ -28,81 +39,61 @@ RSpec.describe Gitlab::Badge::Coverage::Report do
end
end
- shared_examples 'unknown coverage report' do
- context 'particular job specified' do
- let(:job_name) { '' }
-
- it 'returns nil' do
- expect(badge.status).to be_nil
+ describe '#status' do
+ context 'with no job specified' do
+ it 'returns the most recent successful pipeline coverage value' do
+ expect(badge.status).to eq(50.00)
end
- end
- context 'particular job not specified' do
- let(:job_name) { nil }
+ context 'and no successful pipelines' do
+ before do
+ allow(badge).to receive(:successful_pipeline).and_return(nil)
+ end
- it 'returns nil' do
- expect(badge.status).to be_nil
+ it 'returns nil' do
+ expect(badge.status).to eq(nil)
+ end
end
end
- end
- context 'when latest successful pipeline exists' do
- before do
- create_pipeline do |pipeline|
- create(:ci_build, :success, pipeline: pipeline, name: 'first', coverage: 40)
- create(:ci_build, :success, pipeline: pipeline, coverage: 60)
- end
+ context 'with a blank job name' do
+ let(:job_name) { ' ' }
- create_pipeline do |pipeline|
- create(:ci_build, :failed, pipeline: pipeline, coverage: 10)
+ it 'returns the latest successful pipeline coverage value' do
+ expect(badge.status).to eq(50.00)
end
end
- context 'when particular job specified' do
- let(:job_name) { 'first' }
+ context 'with an unmatching job name specified' do
+ let(:job_name) { 'incorrect name' }
- it 'returns coverage for the particular job' do
- expect(badge.status).to eq 40
+ it 'returns nil' do
+ expect(badge.status).to be_nil
end
end
- context 'when particular job not specified' do
- let(:job_name) { '' }
+ context 'with a matching job name specified' do
+ let(:job_name) { 'coverage' }
- it 'returns arithemetic mean for the pipeline' do
- expect(badge.status).to eq 50
+ it 'returns the pipeline coverage value' do
+ expect(badge.status).to eq(40.00)
end
- end
- end
-
- context 'when only failed pipeline exists' do
- before do
- create_pipeline do |pipeline|
- create(:ci_build, :failed, pipeline: pipeline, coverage: 10)
- end
- end
-
- it_behaves_like 'unknown coverage report'
- context 'particular job specified' do
- let(:job_name) { 'nonexistent' }
+ context 'with a more recent running pipeline' do
+ let!(:another_build) { create(:ci_build, :success, pipeline: running_pipeline, coverage: 20, created_at: 7.seconds.ago, name: 'coverage') }
- it 'retruns nil' do
- expect(badge.status).to be_nil
+ it 'returns the running pipeline coverage value' do
+ expect(badge.status).to eq(20.00)
+ end
end
- end
- end
- context 'pipeline does not exist' do
- it_behaves_like 'unknown coverage report'
- end
-
- def create_pipeline
- opts = { project: project, sha: project.commit.id, ref: 'master' }
+ context 'with a more recent failed pipeline' do
+ let!(:another_build) { create(:ci_build, :success, pipeline: failure_pipeline, coverage: 10, created_at: 6.seconds.ago, name: 'coverage') }
- create(:ci_pipeline, opts).tap do |pipeline|
- yield pipeline
- ::Ci::ProcessPipelineService.new(pipeline).execute
+ it 'returns the failed pipeline coverage value' do
+ expect(badge.status).to eq(10.00)
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
index 80ec5ec1fc7..c9ad78ec760 100644
--- a/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
+++ b/spec/lib/gitlab/bitbucket_server_import/importer_spec.rb
@@ -112,7 +112,13 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
allow(subject).to receive(:delete_temp_branches)
allow(subject).to receive(:restore_branches)
- allow(subject.client).to receive(:pull_requests).and_return([pull_request])
+ allow(subject.client).to receive(:pull_requests).and_return([pull_request], [])
+ end
+
+ # As we are using Caching with redis, it is best to clean the cache after each test run, else we need to wait for
+ # the expiration by the importer
+ after do
+ Gitlab::Cache::Import::Caching.expire(subject.already_imported_cache_key, 0)
end
it 'imports merge event' do
@@ -463,6 +469,47 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
subject.execute
end
+
+ describe 'import pull requests with caching' do
+ let(:pull_request_already_imported) do
+ instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: 11)
+ end
+
+ let(:pull_request_to_be_imported) do
+ instance_double(
+ BitbucketServer::Representation::PullRequest,
+ iid: 12,
+ source_branch_sha: sample.commits.last,
+ source_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.source_branch,
+ target_branch_sha: sample.commits.first,
+ target_branch_name: Gitlab::Git::BRANCH_REF_PREFIX + sample.target_branch,
+ title: 'This is a title',
+ description: 'This is a test pull request',
+ state: 'merged',
+ author: 'Test Author',
+ author_email: pull_request_author.email,
+ author_username: pull_request_author.username,
+ created_at: Time.now,
+ updated_at: Time.now,
+ raw: {},
+ merged?: true)
+ end
+
+ before do
+ Gitlab::Cache::Import::Caching.set_add(subject.already_imported_cache_key, pull_request_already_imported.iid)
+ allow(subject.client).to receive(:pull_requests).and_return([pull_request_to_be_imported, pull_request_already_imported], [])
+ end
+
+ it 'only imports one Merge Request, as the other on is in the cache' do
+ expect(subject.client).to receive(:activities).and_return([merge_event])
+ expect { subject.execute }.to change { MergeRequest.count }.by(1)
+
+ expect(Gitlab::Cache::Import::Caching.set_includes?(subject.already_imported_cache_key, pull_request_already_imported.iid)).to eq(true)
+ expect(Gitlab::Cache::Import::Caching.set_includes?(subject.already_imported_cache_key, pull_request_to_be_imported.iid)).to eq(true)
+ end
+ end
end
describe 'inaccessible branches' do
@@ -488,7 +535,7 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
updated_at: Time.now,
merged?: true)
- expect(subject.client).to receive(:pull_requests).and_return([pull_request])
+ expect(subject.client).to receive(:pull_requests).and_return([pull_request], [])
expect(subject.client).to receive(:activities).and_return([])
expect(subject).to receive(:import_repository).twice
end
@@ -525,4 +572,36 @@ RSpec.describe Gitlab::BitbucketServerImport::Importer do
expect { subject.execute }.to change { MergeRequest.count }.by(1)
end
end
+
+ context "lfs files" do
+ before do
+ allow(project).to receive(:lfs_enabled?).and_return(true)
+ allow(subject).to receive(:import_repository)
+ allow(subject).to receive(:import_pull_requests)
+ end
+
+ it "downloads lfs objects if lfs_enabled is enabled for project" do
+ expect_next_instance_of(Projects::LfsPointers::LfsImportService) do |lfs_import_service|
+ expect(lfs_import_service).to receive(:execute).and_return(status: :success)
+ end
+
+ subject.execute
+ end
+
+ it "adds the error message when the lfs download fails" do
+ allow_next_instance_of(Projects::LfsPointers::LfsImportService) do |lfs_import_service|
+ expect(lfs_import_service).to receive(:execute).and_return(status: :error, message: "LFS server not reachable")
+ end
+
+ subject.execute
+
+ expect(project.import_state.reload.last_error).to eq(Gitlab::Json.dump({
+ message: "The remote data could not be fully imported.",
+ errors: [{
+ type: "lfs_objects",
+ errors: "The Lfs import process failed. LFS server not reachable"
+ }]
+ }))
+ end
+ end
end
diff --git a/spec/lib/gitlab/chat/output_spec.rb b/spec/lib/gitlab/chat/output_spec.rb
index 38e17c39fad..34f6bc0904c 100644
--- a/spec/lib/gitlab/chat/output_spec.rb
+++ b/spec/lib/gitlab/chat/output_spec.rb
@@ -8,62 +8,67 @@ RSpec.describe Gitlab::Chat::Output do
end
let(:output) { described_class.new(build) }
+ let(:trace) { Gitlab::Ci::Trace.new(build) }
+
+ before do
+ trace.set("\e[0KRunning with gitlab-runner 13.4.0~beta.108.g2ed41114 (2ed41114)
+\e[0;m\e[0K on GDK local runner g_XWCUS4
+\e[0;msection_start:1604068171:resolve_secrets\r\e[0K\e[0K\e[36;1mResolving secrets\e[0;m
+\e[0;msection_end:1604068171:resolve_secrets\r\e[0Ksection_start:1604068171:prepare_executor\r\e[0K\e[0K\e[36;1mPreparing the \"docker\" executor\e[0;m
+\e[0;m\e[0KUsing Docker executor with image ubuntu:20.04 ...
+\e[0;m\e[0KUsing locally found image version due to if-not-present pull policy
+\e[0;m\e[0KUsing docker image sha256:d70eaf7277eada08fca944de400e7e4dd97b1262c06ed2b1011500caa4decaf1 for ubuntu:20.04 with digest ubuntu@sha256:fff16eea1a8ae92867721d90c59a75652ea66d29c05294e6e2f898704bdb8cf1 ...
+\e[0;msection_end:1604068172:prepare_executor\r\e[0Ksection_start:1604068172:prepare_script\r\e[0K\e[0K\e[36;1mPreparing environment\e[0;m
+\e[0;mRunning on runner-gxwcus4-project-21-concurrent-0 via MacBook-Pro.local...
+section_end:1604068173:prepare_script\r\e[0Ksection_start:1604068173:get_sources\r\e[0K\e[0K\e[36;1mGetting source from Git repository\e[0;m
+\e[0;m\e[32;1mFetching changes with git depth set to 50...\e[0;m
+Initialized empty Git repository in /builds/267388-group-1/playground/.git/
+\e[32;1mCreated fresh repository.\e[0;m
+\e[32;1mChecking out 6c8eb7f4 as master...\e[0;m
+
+\e[32;1mSkipping Git submodules setup\e[0;m
+section_end:1604068175:get_sources\r\e[0Ksection_start:1604068175:step_script\r\e[0K\e[0K\e[36;1mExecuting \"step_script\" stage of the job script\e[0;m
+\e[0;m\e[32;1m$ echo \"success!\"\e[0;m
+success!
+section_end:1604068175:step_script\r\e[0Ksection_start:1604068175:chat_reply\r\033[0K
+Chat Reply
+section_end:1604068176:chat_reply\r\033[0K\e[32;1mJob succeeded
+\e[0;m")
+ end
describe '#to_s' do
- it 'returns the build output as a String' do
- trace = Gitlab::Ci::Trace.new(build)
-
- trace.set("echo hello\nhello")
-
- allow(build)
- .to receive(:trace)
- .and_return(trace)
-
- allow(output)
- .to receive(:read_offset_and_length)
- .and_return([0, 13])
-
- expect(output.to_s).to eq('he')
+ it 'returns the chat reply as a String' do
+ expect(output.to_s).to eq("Chat Reply")
end
- end
- describe '#read_offset_and_length' do
context 'without the chat_reply trace section' do
- it 'falls back to using the build_script trace section' do
- expect(output)
- .to receive(:find_build_trace_section)
- .with('chat_reply')
- .and_return(nil)
-
- expect(output)
- .to receive(:find_build_trace_section)
- .with('build_script')
- .and_return({ name: 'build_script', byte_start: 1, byte_end: 4 })
-
- expect(output.read_offset_and_length).to eq([1, 3])
+ before do
+ trace.set(trace.raw.gsub('chat_reply', 'not_found'))
end
- end
- context 'without the build_script trace section' do
- it 'raises MissingBuildSectionError' do
- expect { output.read_offset_and_length }
- .to raise_error(described_class::MissingBuildSectionError)
+ it 'falls back to using the step_script trace section' do
+ expect(output.to_s).to eq("\e[0;m\e[32;1m$ echo \"success!\"\e[0;m\nsuccess!")
end
- end
-
- context 'with the chat_reply trace section' do
- it 'returns the read offset and length as an Array' do
- trace = Gitlab::Ci::Trace.new(build)
-
- allow(build)
- .to receive(:trace)
- .and_return(trace)
-
- allow(trace)
- .to receive(:extract_sections)
- .and_return([{ name: 'chat_reply', byte_start: 1, byte_end: 4 }])
- expect(output.read_offset_and_length).to eq([1, 3])
+ context 'without the step_script trace section' do
+ before do
+ trace.set(trace.raw.gsub('step_script', 'build_script'))
+ end
+
+ it 'falls back to using the build_script trace section' do
+ expect(output.to_s).to eq("\e[0;m\e[32;1m$ echo \"success!\"\e[0;m\nsuccess!")
+ end
+
+ context 'without the build_script trace section' do
+ before do
+ trace.set(trace.raw.gsub('build_script', 'not_found'))
+ end
+
+ it 'raises MissingBuildSectionError' do
+ expect { output.to_s }
+ .to raise_error(described_class::MissingBuildSectionError)
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb b/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
index cf52f601006..d20ea6c9202 100644
--- a/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
+++ b/spec/lib/gitlab/ci/build/rules/rule/clause/changes_spec.rb
@@ -13,5 +13,47 @@ RSpec.describe Gitlab::Ci::Build::Rules::Rule::Clause::Changes do
subject { described_class.new(globs).satisfied_by?(pipeline, nil) }
end
+
+ context 'when using variable expansion' do
+ let(:pipeline) { build(:ci_pipeline) }
+ let(:modified_paths) { ['helm/test.txt'] }
+ let(:globs) { ['$HELM_DIR/**/*'] }
+ let(:context) { double('context') }
+
+ subject { described_class.new(globs).satisfied_by?(pipeline, context) }
+
+ before do
+ allow(pipeline).to receive(:modified_paths).and_return(modified_paths)
+ end
+
+ context 'when context is nil' do
+ let(:context) {}
+
+ it { is_expected.to be_falsey }
+ end
+
+ context 'when context has the specified variables' do
+ let(:variables) do
+ [{ key: "HELM_DIR", value: "helm", public: true }]
+ end
+
+ before do
+ allow(context).to receive(:variables).and_return(variables)
+ end
+
+ it { is_expected.to be_truthy }
+ end
+
+ context 'when variable expansion does not match' do
+ let(:globs) { ['path/with/$in/it/*'] }
+ let(:modified_paths) { ['path/with/$in/it/file.txt'] }
+
+ before do
+ allow(context).to receive(:variables).and_return([])
+ end
+
+ it { is_expected.to be_truthy }
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/charts_spec.rb b/spec/lib/gitlab/ci/charts_spec.rb
index e00e5ed3920..cfc2019a89b 100644
--- a/spec/lib/gitlab/ci/charts_spec.rb
+++ b/spec/lib/gitlab/ci/charts_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Charts do
- context "yearchart" do
+ context 'yearchart' do
let(:project) { create(:project) }
let(:chart) { Gitlab::Ci::Charts::YearChart.new(project) }
@@ -16,9 +16,13 @@ RSpec.describe Gitlab::Ci::Charts do
it 'starts at the beginning of the current year' do
expect(chart.from).to eq(chart.to.years_ago(1).beginning_of_month.beginning_of_day)
end
+
+ it 'uses %B %Y as labels format' do
+ expect(chart.labels).to include(chart.from.strftime('%B %Y'))
+ end
end
- context "monthchart" do
+ context 'monthchart' do
let(:project) { create(:project) }
let(:chart) { Gitlab::Ci::Charts::MonthChart.new(project) }
@@ -31,9 +35,13 @@ RSpec.describe Gitlab::Ci::Charts do
it 'starts one month ago' do
expect(chart.from).to eq(1.month.ago.beginning_of_day)
end
+
+ it 'uses %d %B as labels format' do
+ expect(chart.labels).to include(chart.from.strftime('%d %B'))
+ end
end
- context "weekchart" do
+ context 'weekchart' do
let(:project) { create(:project) }
let(:chart) { Gitlab::Ci::Charts::WeekChart.new(project) }
@@ -46,9 +54,13 @@ RSpec.describe Gitlab::Ci::Charts do
it 'starts one week ago' do
expect(chart.from).to eq(1.week.ago.beginning_of_day)
end
+
+ it 'uses %d %B as labels format' do
+ expect(chart.labels).to include(chart.from.strftime('%d %B'))
+ end
end
- context "pipeline_times" do
+ context 'pipeline_times' do
let(:project) { create(:project) }
let(:chart) { Gitlab::Ci::Charts::PipelineTime.new(project) }
diff --git a/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb b/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
index 3388ae0af2f..ff44a235ea5 100644
--- a/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/matrix_spec.rb
@@ -46,98 +46,53 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Matrix do
end
end
- context 'with one_dimensional_matrix feature flag enabled' do
- before do
- stub_feature_flags(one_dimensional_matrix: true)
- matrix.compose!
+ context 'when entry config has only one variable with multiple values' do
+ let(:config) do
+ [
+ {
+ 'VAR_1' => %w[build test]
+ }
+ ]
end
- context 'when entry config has only one variable with multiple values' do
- let(:config) do
- [
- {
- 'VAR_1' => %w[build test]
- }
- ]
- end
-
- describe '#valid?' do
- it { is_expected.to be_valid }
- end
+ describe '#valid?' do
+ it { is_expected.to be_valid }
+ end
- describe '#errors' do
- it 'returns no errors' do
- expect(matrix.errors)
- .to be_empty
- end
+ describe '#errors' do
+ it 'returns no errors' do
+ expect(matrix.errors)
+ .to be_empty
end
+ end
- describe '#value' do
- before do
- matrix.compose!
- end
-
- it 'returns the value without raising an error' do
- expect(matrix.value).to eq([{ 'VAR_1' => %w[build test] }])
- end
+ describe '#value' do
+ before do
+ matrix.compose!
end
- context 'when entry config has only one variable with one value' do
- let(:config) do
- [
- {
- 'VAR_1' => %w[test]
- }
- ]
- end
-
- describe '#valid?' do
- it { is_expected.to be_valid }
- end
-
- describe '#errors' do
- it 'returns no errors' do
- expect(matrix.errors)
- .to be_empty
- end
- end
-
- describe '#value' do
- before do
- matrix.compose!
- end
-
- it 'returns the value without raising an error' do
- expect(matrix.value).to eq([{ 'VAR_1' => %w[test] }])
- end
- end
+ it 'returns the value without raising an error' do
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[build test] }])
end
end
- end
- context 'with one_dimensional_matrix feature flag disabled' do
- before do
- stub_feature_flags(one_dimensional_matrix: false)
- matrix.compose!
- end
-
- context 'when entry config has only one variable with multiple values' do
+ context 'when entry config has only one variable with one value' do
let(:config) do
[
{
- 'VAR_1' => %w[build test]
+ 'VAR_1' => %w[test]
}
]
end
describe '#valid?' do
- it { is_expected.not_to be_valid }
+ it { is_expected.to be_valid }
end
describe '#errors' do
- it 'returns error about too many jobs' do
+ it 'returns no errors' do
expect(matrix.errors)
- .to include('variables config requires at least 2 items')
+ .to be_empty
end
end
@@ -147,38 +102,7 @@ RSpec.describe ::Gitlab::Ci::Config::Entry::Product::Matrix do
end
it 'returns the value without raising an error' do
- expect(matrix.value).to eq([{ 'VAR_1' => %w[build test] }])
- end
- end
-
- context 'when entry config has only one variable with one value' do
- let(:config) do
- [
- {
- 'VAR_1' => %w[test]
- }
- ]
- end
-
- describe '#valid?' do
- it { is_expected.not_to be_valid }
- end
-
- describe '#errors' do
- it 'returns no errors' do
- expect(matrix.errors)
- .to include('variables config requires at least 2 items')
- end
- end
-
- describe '#value' do
- before do
- matrix.compose!
- end
-
- it 'returns the value without raising an error' do
- expect(matrix.value).to eq([{ 'VAR_1' => %w[test] }])
- end
+ expect(matrix.value).to eq([{ 'VAR_1' => %w[test] }])
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb b/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
index 407efb438b5..5e920ce34e0 100644
--- a/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/product/variables_spec.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
-# After Feature one_dimensional_matrix is removed, this can be changed back to fast_spec_helper
-require 'spec_helper'
+require 'fast_spec_helper'
require_dependency 'active_model'
RSpec.describe Gitlab::Ci::Config::Entry::Product::Variables do
@@ -46,70 +45,18 @@ RSpec.describe Gitlab::Ci::Config::Entry::Product::Variables do
end
end
- context 'with one_dimensional_matrix feature flag enabled' do
- context 'with only one variable' do
- before do
- stub_feature_flags(one_dimensional_matrix: true)
- end
- let(:config) { { VAR: 'test' } }
-
- describe '#valid?' do
- it 'is valid' do
- expect(entry).to be_valid
- end
- end
+ context 'with only one variable' do
+ let(:config) { { VAR: 'test' } }
- describe '#errors' do
- it 'does not append errors' do
- expect(entry.errors).to be_empty
- end
+ describe '#valid?' do
+ it 'is valid' do
+ expect(entry).to be_valid
end
end
- end
-
- context 'with one_dimensional_matrix feature flag disabled' do
- context 'when entry value is not correct' do
- before do
- stub_feature_flags(one_dimensional_matrix: false)
- end
- shared_examples 'invalid variables' do |message|
- describe '#errors' do
- it 'saves errors' do
- expect(entry.errors).to include(message)
- end
- end
-
- describe '#valid?' do
- it 'is not valid' do
- expect(entry).not_to be_valid
- end
- end
- end
-
- context 'with array' do
- let(:config) { [:VAR, 'test'] }
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
-
- context 'with empty array' do
- let(:config) { { VAR: 'test', VAR2: [] } }
-
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
-
- context 'with nested array' do
- let(:config) { { VAR: 'test', VAR2: [1, [2]] } }
-
- it_behaves_like 'invalid variables', /should be a hash of key value pairs/
- end
-
- context 'with one_dimensional_matrix feature flag disabled' do
- context 'with only one variable' do
- let(:config) { { VAR: 'test' } }
-
- it_behaves_like 'invalid variables', /variables config requires at least 2 items/
- end
+ describe '#errors' do
+ it 'does not append errors' do
+ expect(entry.errors).to be_empty
end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/mapper_spec.rb b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
index bf14d8d6b34..7ad57827e30 100644
--- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb
@@ -100,6 +100,42 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
expect { subject }.to raise_error(described_class::AmbigiousSpecificationError)
end
end
+
+ context "when the key is a project's file" do
+ let(:values) do
+ { include: { project: project.full_path, file: local_file },
+ image: 'ruby:2.7' }
+ end
+
+ it 'returns File instances' do
+ expect(subject).to contain_exactly(
+ an_instance_of(Gitlab::Ci::Config::External::File::Project))
+ end
+ end
+
+ context "when the key is project's files" do
+ let(:values) do
+ { include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] },
+ image: 'ruby:2.7' }
+ end
+
+ it 'returns two File instances' do
+ expect(subject).to contain_exactly(
+ an_instance_of(Gitlab::Ci::Config::External::File::Project),
+ an_instance_of(Gitlab::Ci::Config::External::File::Project))
+ end
+
+ context 'when FF ci_include_multiple_files_from_project is disabled' do
+ before do
+ stub_feature_flags(ci_include_multiple_files_from_project: false)
+ end
+
+ it 'returns a File instance' do
+ expect(subject).to contain_exactly(
+ an_instance_of(Gitlab::Ci::Config::External::File::Project))
+ end
+ end
+ end
end
context "when 'include' is defined as an array" do
@@ -161,6 +197,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
it 'raises an exception' do
expect { subject }.to raise_error(described_class::DuplicateIncludesError)
end
+
+ context 'when including multiple files from a project' do
+ let(:values) do
+ { include: { project: project.full_path, file: [local_file, local_file] } }
+ end
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(described_class::DuplicateIncludesError)
+ end
+ end
end
context "when too many 'includes' are defined" do
@@ -179,6 +225,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do
it 'raises an exception' do
expect { subject }.to raise_error(described_class::TooManyIncludesError)
end
+
+ context 'when including multiple files from a project' do
+ let(:values) do
+ { include: { project: project.full_path, file: [local_file, 'another_file_path.yml'] } }
+ end
+
+ it 'raises an exception' do
+ expect { subject }.to raise_error(described_class::TooManyIncludesError)
+ end
+ end
end
end
end
diff --git a/spec/lib/gitlab/ci/config/external/processor_spec.rb b/spec/lib/gitlab/ci/config/external/processor_spec.rb
index 9786e050399..150a2ec2929 100644
--- a/spec/lib/gitlab/ci/config/external/processor_spec.rb
+++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb
@@ -302,5 +302,82 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do
end
end
end
+
+ context 'when a valid project file is defined' do
+ let(:values) do
+ {
+ include: { project: another_project.full_path, file: '/templates/my-build.yml' },
+ image: 'ruby:2.7'
+ }
+ end
+
+ before do
+ another_project.add_developer(user)
+
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-build.yml') do
+ <<~HEREDOC
+ my_build:
+ script: echo Hello World
+ HEREDOC
+ end
+ end
+ end
+
+ it 'appends the file to the values' do
+ output = processor.perform
+ expect(output.keys).to match_array([:image, :my_build])
+ end
+ end
+
+ context 'when valid project files are defined in a single include' do
+ let(:values) do
+ {
+ include: {
+ project: another_project.full_path,
+ file: ['/templates/my-build.yml', '/templates/my-test.yml']
+ },
+ image: 'ruby:2.7'
+ }
+ end
+
+ before do
+ another_project.add_developer(user)
+
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-build.yml') do
+ <<~HEREDOC
+ my_build:
+ script: echo Hello World
+ HEREDOC
+ end
+
+ allow(repository).to receive(:blob_data_at).with(another_project.commit.id, '/templates/my-test.yml') do
+ <<~HEREDOC
+ my_test:
+ script: echo Hello World
+ HEREDOC
+ end
+ end
+ end
+
+ it 'appends the file to the values' do
+ output = processor.perform
+ expect(output.keys).to match_array([:image, :my_build, :my_test])
+ end
+
+ context 'when FF ci_include_multiple_files_from_project is disabled' do
+ before do
+ stub_feature_flags(ci_include_multiple_files_from_project: false)
+ end
+
+ it 'raises an error' do
+ expect { processor.perform }.to raise_error(
+ described_class::IncludeError,
+ 'Included file `["/templates/my-build.yml", "/templates/my-test.yml"]` needs to be a string'
+ )
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb
index 41a45fe4ab7..b5a0f0e3fd7 100644
--- a/spec/lib/gitlab/ci/config_spec.rb
+++ b/spec/lib/gitlab/ci/config_spec.rb
@@ -246,6 +246,14 @@ RSpec.describe Gitlab::Ci::Config do
let(:remote_location) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' }
let(:local_location) { 'spec/fixtures/gitlab/ci/external_files/.gitlab-ci-template-1.yml' }
+ let(:local_file_content) do
+ File.read(Rails.root.join(local_location))
+ end
+
+ let(:local_location_hash) do
+ YAML.safe_load(local_file_content).deep_symbolize_keys
+ end
+
let(:remote_file_content) do
<<~HEREDOC
variables:
@@ -256,8 +264,8 @@ RSpec.describe Gitlab::Ci::Config do
HEREDOC
end
- let(:local_file_content) do
- File.read(Rails.root.join(local_location))
+ let(:remote_file_hash) do
+ YAML.safe_load(remote_file_content).deep_symbolize_keys
end
let(:gitlab_ci_yml) do
@@ -283,22 +291,11 @@ RSpec.describe Gitlab::Ci::Config do
context "when gitlab_ci_yml has valid 'include' defined" do
it 'returns a composed hash' do
- before_script_values = [
- "apt-get update -qq && apt-get install -y -qq sqlite3 libsqlite3-dev nodejs", "ruby -v",
- "which ruby",
- "bundle install --jobs $(nproc) \"${FLAGS[@]}\""
- ]
- variables = {
- POSTGRES_USER: "user",
- POSTGRES_PASSWORD: "testing-password",
- POSTGRES_ENABLED: "true",
- POSTGRES_DB: "$CI_ENVIRONMENT_SLUG"
- }
composed_hash = {
- before_script: before_script_values,
+ before_script: local_location_hash[:before_script],
image: "ruby:2.7",
rspec: { script: ["bundle exec rspec"] },
- variables: variables
+ variables: remote_file_hash[:variables]
}
expect(config.to_hash).to eq(composed_hash)
@@ -575,5 +572,56 @@ RSpec.describe Gitlab::Ci::Config do
)
end
end
+
+ context "when including multiple files from a project" do
+ let(:other_file_location) { 'my_builds.yml' }
+
+ let(:other_file_content) do
+ <<~HEREDOC
+ build:
+ stage: build
+ script: echo hello
+
+ rspec:
+ stage: test
+ script: bundle exec rspec
+ HEREDOC
+ end
+
+ let(:gitlab_ci_yml) do
+ <<~HEREDOC
+ include:
+ - project: #{project.full_path}
+ file:
+ - #{local_location}
+ - #{other_file_location}
+
+ image: ruby:2.7
+ HEREDOC
+ end
+
+ before do
+ project.add_developer(user)
+
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).with(an_instance_of(String), local_location)
+ .and_return(local_file_content)
+
+ allow(repository).to receive(:blob_data_at).with(an_instance_of(String), other_file_location)
+ .and_return(other_file_content)
+ end
+ end
+
+ it 'returns a composed hash' do
+ composed_hash = {
+ before_script: local_location_hash[:before_script],
+ image: "ruby:2.7",
+ build: { stage: "build", script: "echo hello" },
+ rspec: { stage: "test", script: "bundle exec rspec" }
+ }
+
+ expect(config.to_hash).to eq(composed_hash)
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/jwt_spec.rb b/spec/lib/gitlab/ci/jwt_spec.rb
index 9b133efad9c..3130c0c0c41 100644
--- a/spec/lib/gitlab/ci/jwt_spec.rb
+++ b/spec/lib/gitlab/ci/jwt_spec.rb
@@ -93,32 +93,65 @@ RSpec.describe Gitlab::Ci::Jwt do
end
describe '.for_build' do
- let(:rsa_key) { OpenSSL::PKey::RSA.new(Rails.application.secrets.openid_connect_signing_key) }
+ shared_examples 'generating JWT for build' do
+ context 'when signing key is present' do
+ let(:rsa_key) { OpenSSL::PKey::RSA.generate(1024) }
+ let(:rsa_key_data) { rsa_key.to_s }
- subject(:jwt) { described_class.for_build(build) }
+ it 'generates JWT with key id' do
+ _payload, headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
+
+ expect(headers['kid']).to eq(rsa_key.public_key.to_jwk['kid'])
+ end
+
+ it 'generates JWT for the given job with ttl equal to build timeout' do
+ expect(build).to receive(:metadata_timeout).and_return(3_600)
+
+ payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
+ ttl = payload["exp"] - payload["iat"]
+
+ expect(ttl).to eq(3_600)
+ end
+
+ it 'generates JWT for the given job with default ttl if build timeout is not set' do
+ expect(build).to receive(:metadata_timeout).and_return(nil)
+
+ payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
+ ttl = payload["exp"] - payload["iat"]
- it 'generates JWT with key id' do
- _payload, headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
+ expect(ttl).to eq(5.minutes.to_i)
+ end
+ end
+
+ context 'when signing key is missing' do
+ let(:rsa_key_data) { nil }
- expect(headers['kid']).to eq(rsa_key.public_key.to_jwk['kid'])
+ it 'raises NoSigningKeyError' do
+ expect { jwt }.to raise_error described_class::NoSigningKeyError
+ end
+ end
end
- it 'generates JWT for the given job with ttl equal to build timeout' do
- expect(build).to receive(:metadata_timeout).and_return(3_600)
+ subject(:jwt) { described_class.for_build(build) }
+
+ context 'when ci_jwt_signing_key feature flag is disabled' do
+ before do
+ stub_feature_flags(ci_jwt_signing_key: false)
- payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
- ttl = payload["exp"] - payload["iat"]
+ allow(Rails.application.secrets).to receive(:openid_connect_signing_key).and_return(rsa_key_data)
+ end
- expect(ttl).to eq(3_600)
+ it_behaves_like 'generating JWT for build'
end
- it 'generates JWT for the given job with default ttl if build timeout is not set' do
- expect(build).to receive(:metadata_timeout).and_return(nil)
+ context 'when ci_jwt_signing_key feature flag is enabled' do
+ before do
+ stub_feature_flags(ci_jwt_signing_key: true)
- payload, _headers = JWT.decode(jwt, rsa_key.public_key, true, { algorithm: 'RS256' })
- ttl = payload["exp"] - payload["iat"]
+ stub_application_setting(ci_jwt_signing_key: rsa_key_data)
+ end
- expect(ttl).to eq(5.minutes.to_i)
+ it_behaves_like 'generating JWT for build'
end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
new file mode 100644
index 00000000000..3eaecb11ae0
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/chain/cancel_pending_pipelines_spec.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+ let(:prev_pipeline) { create(:ci_pipeline, project: project) }
+ let(:new_commit) { create(:commit, project: project) }
+ let(:pipeline) { create(:ci_pipeline, project: project, sha: new_commit.sha) }
+
+ let(:command) do
+ Gitlab::Ci::Pipeline::Chain::Command.new(project: project, current_user: user)
+ end
+
+ let(:step) { described_class.new(pipeline, command) }
+
+ before do
+ create(:ci_build, :interruptible, :running, pipeline: prev_pipeline)
+ create(:ci_build, :interruptible, :success, pipeline: prev_pipeline)
+ create(:ci_build, :created, pipeline: prev_pipeline)
+
+ create(:ci_build, :interruptible, pipeline: pipeline)
+ end
+
+ describe '#perform!' do
+ subject(:perform) { step.perform! }
+
+ before do
+ expect(build_statuses(prev_pipeline)).to contain_exactly('running', 'success', 'created')
+ expect(build_statuses(pipeline)).to contain_exactly('pending')
+ end
+
+ context 'when auto-cancel is enabled' do
+ before do
+ project.update!(auto_cancel_pending_pipelines: 'enabled')
+ end
+
+ it 'cancels only previous interruptible builds' do
+ perform
+
+ expect(build_statuses(prev_pipeline)).to contain_exactly('canceled', 'success', 'canceled')
+ expect(build_statuses(pipeline)).to contain_exactly('pending')
+ end
+
+ context 'when the previous pipeline has a child pipeline' do
+ let(:child_pipeline) { create(:ci_pipeline, child_of: prev_pipeline) }
+
+ context 'when the child pipeline has an interruptible job' do
+ before do
+ create(:ci_build, :interruptible, :running, pipeline: child_pipeline)
+ end
+
+ it 'cancels interruptible builds of child pipeline' do
+ expect(build_statuses(child_pipeline)).to contain_exactly('running')
+
+ perform
+
+ expect(build_statuses(child_pipeline)).to contain_exactly('canceled')
+ end
+
+ context 'when FF ci_auto_cancel_all_pipelines is disabled' do
+ before do
+ stub_feature_flags(ci_auto_cancel_all_pipelines: false)
+ end
+
+ it 'does not cancel interruptible builds of child pipeline' do
+ expect(build_statuses(child_pipeline)).to contain_exactly('running')
+
+ perform
+
+ expect(build_statuses(child_pipeline)).to contain_exactly('running')
+ end
+ end
+ end
+
+ context 'when the child pipeline has not an interruptible job' do
+ before do
+ create(:ci_build, :running, pipeline: child_pipeline)
+ end
+
+ it 'does not cancel the build of child pipeline' do
+ expect(build_statuses(child_pipeline)).to contain_exactly('running')
+
+ perform
+
+ expect(build_statuses(child_pipeline)).to contain_exactly('running')
+ end
+ end
+ end
+
+ context 'when the prev pipeline source is webide' do
+ let(:prev_pipeline) { create(:ci_pipeline, :webide, project: project) }
+
+ it 'does not cancel builds of the previous pipeline' do
+ perform
+
+ expect(build_statuses(prev_pipeline)).to contain_exactly('created', 'running', 'success')
+ expect(build_statuses(pipeline)).to contain_exactly('pending')
+ end
+ end
+ end
+
+ context 'when auto-cancel is disabled' do
+ before do
+ project.update!(auto_cancel_pending_pipelines: 'disabled')
+ end
+
+ it 'does not cancel any build' do
+ subject
+
+ expect(build_statuses(prev_pipeline)).to contain_exactly('running', 'success', 'created')
+ expect(build_statuses(pipeline)).to contain_exactly('pending')
+ end
+ end
+ end
+
+ private
+
+ def build_statuses(pipeline)
+ pipeline.builds.pluck(:status)
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb
index 8c02121857a..5506b079d0f 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/populate_spec.rb
@@ -22,6 +22,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate do
[
Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command),
+ Gitlab::Ci::Pipeline::Chain::SeedBlock.new(pipeline, command),
Gitlab::Ci::Pipeline::Chain::Seed.new(pipeline, command)
]
end
@@ -180,23 +181,21 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Populate do
->(pipeline) { pipeline.variables.create!(key: 'VAR', value: '123') }
end
- it 'wastes pipeline iid' do
- expect { run_chain }.to raise_error(ActiveRecord::RecordNotSaved)
-
- last_iid = InternalId.ci_pipelines
- .where(project_id: project.id)
- .last.last_value
-
- expect(last_iid).to be > 0
+ it 'raises error' do
+ expect { run_chain }.to raise_error(ActiveRecord::RecordNotSaved,
+ 'You cannot call create unless the parent is saved')
end
end
end
context 'when pipeline gets persisted during the process' do
- let(:pipeline) { create(:ci_pipeline, project: project) }
+ before do
+ dependencies.each(&:perform!)
+ pipeline.save!
+ end
it 'raises error' do
- expect { run_chain }.to raise_error(described_class::PopulateError)
+ expect { step.perform! }.to raise_error(described_class::PopulateError)
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb
new file mode 100644
index 00000000000..85c8e20767f
--- /dev/null
+++ b/spec/lib/gitlab/ci/pipeline/chain/seed_block_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Pipeline::Chain::SeedBlock do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user, developer_projects: [project]) }
+ let(:seeds_block) { }
+
+ let(:command) do
+ Gitlab::Ci::Pipeline::Chain::Command.new(
+ project: project,
+ current_user: user,
+ origin_ref: 'master',
+ seeds_block: seeds_block)
+ end
+
+ let(:pipeline) { build(:ci_pipeline, project: project) }
+
+ describe '#perform!' do
+ before do
+ stub_ci_pipeline_yaml_file(YAML.dump(config))
+ end
+
+ subject(:run_chain) do
+ [
+ Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command),
+ Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command)
+ ].map(&:perform!)
+
+ described_class.new(pipeline, command).perform!
+ end
+
+ let(:config) do
+ { rspec: { script: 'rake' } }
+ end
+
+ context 'when there is not seeds_block' do
+ it 'does nothing' do
+ expect { run_chain }.not_to raise_error
+ end
+ end
+
+ context 'when there is seeds_block' do
+ let(:seeds_block) do
+ ->(pipeline) { pipeline.variables.build(key: 'VAR', value: '123') }
+ end
+
+ it 'executes the block' do
+ run_chain
+
+ expect(pipeline.variables.size).to eq(1)
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'does not execute the block' do
+ run_chain
+
+ expect(pipeline.variables.size).to eq(0)
+ end
+ end
+ end
+
+ context 'when the seeds_block tries to save the pipelie' do
+ let(:seeds_block) do
+ ->(pipeline) { pipeline.save! }
+ end
+
+ it 'raises error' do
+ expect { run_chain }.to raise_error('Pipeline cannot be persisted by `seeds_block`')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb
index f83cd49d780..d849c768a3c 100644
--- a/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/chain/seed_spec.rb
@@ -5,22 +5,14 @@ require 'spec_helper'
RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
let(:project) { create(:project, :repository) }
let(:user) { create(:user, developer_projects: [project]) }
+ let(:seeds_block) { }
let(:command) do
Gitlab::Ci::Pipeline::Chain::Command.new(
project: project,
current_user: user,
origin_ref: 'master',
- seeds_block: nil)
- end
-
- def run_chain(pipeline, command)
- [
- Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command),
- Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command)
- ].map(&:perform!)
-
- described_class.new(pipeline, command).perform!
+ seeds_block: seeds_block)
end
let(:pipeline) { build(:ci_pipeline, project: project) }
@@ -28,22 +20,36 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
describe '#perform!' do
before do
stub_ci_pipeline_yaml_file(YAML.dump(config))
- run_chain(pipeline, command)
end
let(:config) do
{ rspec: { script: 'rake' } }
end
+ subject(:run_chain) do
+ [
+ Gitlab::Ci::Pipeline::Chain::Config::Content.new(pipeline, command),
+ Gitlab::Ci::Pipeline::Chain::Config::Process.new(pipeline, command)
+ ].map(&:perform!)
+
+ described_class.new(pipeline, command).perform!
+ end
+
it 'allocates next IID' do
+ run_chain
+
expect(pipeline.iid).to be_present
end
it 'ensures ci_ref' do
+ run_chain
+
expect(pipeline.ci_ref).to be_present
end
it 'sets the seeds in the command object' do
+ run_chain
+
expect(command.stage_seeds).to all(be_a Gitlab::Ci::Pipeline::Seed::Base)
expect(command.stage_seeds.count).to eq 1
end
@@ -58,6 +64,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
end
it 'correctly fabricates a stage seeds object' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 2
expect(seeds.first.attributes[:name]).to eq 'test'
@@ -81,6 +89,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
end
it 'returns stage seeds only assigned to master' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 1
@@ -100,6 +110,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
end
it 'returns stage seeds only assigned to schedules' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 1
@@ -127,6 +139,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
let(:pipeline) { build(:ci_pipeline, project: project) }
it 'returns seeds for kubernetes dependent job' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 2
@@ -138,6 +152,8 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
context 'when kubernetes is not active' do
it 'does not return seeds for kubernetes dependent job' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 1
@@ -155,11 +171,39 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::Seed do
end
it 'returns stage seeds only when variables expression is truthy' do
+ run_chain
+
seeds = command.stage_seeds
expect(seeds.size).to eq 1
expect(seeds.dig(0, 0, :name)).to eq 'unit'
end
end
+
+ context 'when there is seeds_block' do
+ let(:seeds_block) do
+ ->(pipeline) { pipeline.variables.build(key: 'VAR', value: '123') }
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is enabled' do
+ it 'does not execute the block' do
+ run_chain
+
+ expect(pipeline.variables.size).to eq(0)
+ end
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'executes the block' do
+ run_chain
+
+ expect(pipeline.variables.size).to eq(1)
+ end
+ end
+ end
end
end
diff --git a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
index 0c8a0de2f34..e62bf042fba 100644
--- a/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
+++ b/spec/lib/gitlab/ci/pipeline/seed/environment_spec.rb
@@ -16,20 +16,37 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do
subject { seed.to_resource }
shared_examples_for 'returning a correct environment' do
+ let(:expected_auto_stop_in_seconds) do
+ if expected_auto_stop_in
+ ChronicDuration.parse(expected_auto_stop_in).seconds
+ end
+ end
+
it 'returns a persisted environment object' do
- expect { subject }.to change { Environment.count }.by(1)
+ freeze_time do
+ expect { subject }.to change { Environment.count }.by(1)
- expect(subject).to be_a(Environment)
- expect(subject).to be_persisted
- expect(subject.project).to eq(project)
- expect(subject.name).to eq(expected_environment_name)
+ expect(subject).to be_a(Environment)
+ expect(subject).to be_persisted
+ expect(subject.project).to eq(project)
+ expect(subject.name).to eq(expected_environment_name)
+ expect(subject.auto_stop_in).to eq(expected_auto_stop_in_seconds)
+ end
end
context 'when environment has already existed' do
- let!(:environment) { create(:environment, project: project, name: expected_environment_name) }
+ let!(:environment) do
+ create(:environment,
+ project: project,
+ name: expected_environment_name
+ ).tap do |env|
+ env.auto_stop_in = expected_auto_stop_in
+ end
+ end
it 'returns the existing environment object' do
expect { subject }.not_to change { Environment.count }
+ expect { subject }.not_to change { environment.auto_stop_at }
expect(subject).to be_persisted
expect(subject).to eq(environment)
@@ -37,9 +54,10 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do
end
end
- context 'when job has environment attribute' do
+ context 'when job has environment name attribute' do
let(:environment_name) { 'production' }
let(:expected_environment_name) { 'production' }
+ let(:expected_auto_stop_in) { nil }
let(:attributes) do
{
@@ -49,11 +67,41 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do
end
it_behaves_like 'returning a correct environment'
+
+ context 'and job environment also has an auto_stop_in attribute' do
+ let(:environment_auto_stop_in) { '5 minutes' }
+ let(:expected_auto_stop_in) { '5 minutes' }
+
+ let(:attributes) do
+ {
+ environment: environment_name,
+ options: {
+ environment: {
+ name: environment_name,
+ auto_stop_in: environment_auto_stop_in
+ }
+ }
+ }
+ end
+
+ it_behaves_like 'returning a correct environment'
+
+ context 'but the environment auto_stop_in on create flag is disabled' do
+ let(:expected_auto_stop_in) { nil }
+
+ before do
+ stub_feature_flags(environment_auto_stop_start_on_create: false)
+ end
+
+ it_behaves_like 'returning a correct environment'
+ end
+ end
end
context 'when job starts a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{job.ref}" }
+ let(:expected_auto_stop_in) { nil }
let(:attributes) do
{
@@ -68,6 +116,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Seed::Environment do
context 'when job stops a review app' do
let(:environment_name) { 'review/$CI_COMMIT_REF_NAME' }
let(:expected_environment_name) { "review/#{job.ref}" }
+ let(:expected_auto_stop_in) { nil }
let(:attributes) do
{
diff --git a/spec/lib/gitlab/ci/reports/test_case_spec.rb b/spec/lib/gitlab/ci/reports/test_case_spec.rb
index a142846fc18..668a475514e 100644
--- a/spec/lib/gitlab/ci/reports/test_case_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_case_spec.rb
@@ -2,13 +2,13 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Reports::TestCase do
+RSpec.describe Gitlab::Ci::Reports::TestCase, :aggregate_failures do
describe '#initialize' do
let(:test_case) { described_class.new(params) }
context 'when required params are given' do
let(:job) { build(:ci_build) }
- let(:params) { attributes_for(:test_case).merge!(job: job) }
+ let(:params) { attributes_for(:report_test_case).merge!(job: job) }
it 'initializes an instance', :aggregate_failures do
expect { test_case }.not_to raise_error
@@ -31,7 +31,7 @@ RSpec.describe Gitlab::Ci::Reports::TestCase do
shared_examples 'param is missing' do |param|
let(:job) { build(:ci_build) }
- let(:params) { attributes_for(:test_case).merge!(job: job) }
+ let(:params) { attributes_for(:report_test_case).merge!(job: job) }
it 'raises an error' do
params.delete(param)
@@ -55,7 +55,7 @@ RSpec.describe Gitlab::Ci::Reports::TestCase do
context 'when attachment is present' do
let_it_be(:job) { create(:ci_build) }
- let(:attachment_test_case) { build(:test_case, :failed_with_attachment, job: job) }
+ let(:attachment_test_case) { build(:report_test_case, :failed_with_attachment, job: job) }
it "initializes the attachment if present" do
expect(attachment_test_case.attachment).to eq("some/path.png")
@@ -71,7 +71,7 @@ RSpec.describe Gitlab::Ci::Reports::TestCase do
end
context 'when attachment is missing' do
- let(:test_case) { build(:test_case) }
+ let(:test_case) { build(:report_test_case) }
it '#has_attachment?' do
expect(test_case.has_attachment?).to be_falsy
@@ -82,4 +82,17 @@ RSpec.describe Gitlab::Ci::Reports::TestCase do
end
end
end
+
+ describe '#set_recent_failures' do
+ it 'sets the recent_failures information' do
+ test_case = build(:report_test_case)
+
+ test_case.set_recent_failures(1, 'master')
+
+ expect(test_case.recent_failures).to eq(
+ count: 1,
+ base_branch: 'master'
+ )
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/reports/test_failure_history_spec.rb b/spec/lib/gitlab/ci/reports/test_failure_history_spec.rb
new file mode 100644
index 00000000000..8df34eddffd
--- /dev/null
+++ b/spec/lib/gitlab/ci/reports/test_failure_history_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::Reports::TestFailureHistory, :aggregate_failures do
+ include TestReportsHelper
+
+ describe '#load!' do
+ let_it_be(:project) { create(:project) }
+ let(:failed_rspec) { create_test_case_rspec_failed }
+ let(:failed_java) { create_test_case_java_failed }
+
+ subject(:load_history) { described_class.new([failed_rspec, failed_java], project).load! }
+
+ before do
+ allow(Ci::TestCaseFailure)
+ .to receive(:recent_failures_count)
+ .with(project: project, test_case_keys: [failed_rspec.key, failed_java.key])
+ .and_return(
+ failed_rspec.key => 2,
+ failed_java.key => 1
+ )
+ end
+
+ it 'sets the recent failures for each matching failed test case in all test suites' do
+ load_history
+
+ expect(failed_rspec.recent_failures).to eq(count: 2, base_branch: 'master')
+ expect(failed_java.recent_failures).to eq(count: 1, base_branch: 'master')
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(test_failure_history: false)
+ end
+
+ it 'does not set recent failures' do
+ load_history
+
+ expect(failed_rspec.recent_failures).to be_nil
+ expect(failed_java.recent_failures).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/reports/test_reports_spec.rb b/spec/lib/gitlab/ci/reports/test_reports_spec.rb
index 502859852f2..24c00de3731 100644
--- a/spec/lib/gitlab/ci/reports/test_reports_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_reports_spec.rb
@@ -110,7 +110,7 @@ RSpec.describe Gitlab::Ci::Reports::TestReports do
end
describe '#with_attachment' do
- let(:test_case) { build(:test_case, :failed) }
+ let(:test_case) { build(:report_test_case, :failed) }
subject { test_reports.with_attachment! }
@@ -126,8 +126,8 @@ RSpec.describe Gitlab::Ci::Reports::TestReports do
end
context 'when test suites contain an attachment' do
- let(:test_case_succes) { build(:test_case) }
- let(:test_case_with_attachment) { build(:test_case, :failed_with_attachment) }
+ let(:test_case_succes) { build(:report_test_case) }
+ let(:test_case_with_attachment) { build(:report_test_case, :failed_with_attachment) }
before do
test_reports.get_suite('rspec').add_test_case(test_case_succes)
diff --git a/spec/lib/gitlab/ci/reports/test_suite_comparer_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_comparer_spec.rb
index 6bb6771678a..c44d32ddb7d 100644
--- a/spec/lib/gitlab/ci/reports/test_suite_comparer_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_suite_comparer_spec.rb
@@ -2,11 +2,11 @@
require 'spec_helper'
-RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
+RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer, :aggregate_failures do
include TestReportsHelper
let(:comparer) { described_class.new(name, base_suite, head_suite) }
- let(:name) { 'rpsec' }
+ let(:name) { 'rspec' }
let(:base_suite) { Gitlab::Ci::Reports::TestSuite.new(name) }
let(:head_suite) { Gitlab::Ci::Reports::TestSuite.new(name) }
let(:test_case_success) { create_test_case_java_success }
@@ -16,7 +16,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
describe '#new_failures' do
subject { comparer.new_failures }
- context 'when head sutie has a newly failed test case which does not exist in base' do
+ context 'when head suite has a newly failed test case which does not exist in base' do
before do
base_suite.add_test_case(test_case_success)
head_suite.add_test_case(test_case_failed)
@@ -27,7 +27,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie still has a failed test case which failed in base' do
+ context 'when head suite still has a failed test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_failed)
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie has a success test case which failed in base' do
+ context 'when head suite has a success test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_success)
@@ -53,7 +53,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
describe '#existing_failures' do
subject { comparer.existing_failures }
- context 'when head sutie has a newly failed test case which does not exist in base' do
+ context 'when head suite has a newly failed test case which does not exist in base' do
before do
base_suite.add_test_case(test_case_success)
head_suite.add_test_case(test_case_failed)
@@ -64,7 +64,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie still has a failed test case which failed in base' do
+ context 'when head suite still has a failed test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_failed)
@@ -75,7 +75,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie has a success test case which failed in base' do
+ context 'when head suite has a success test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_success)
@@ -90,7 +90,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
describe '#resolved_failures' do
subject { comparer.resolved_failures }
- context 'when head sutie has a newly failed test case which does not exist in base' do
+ context 'when head suite has a newly failed test case which does not exist in base' do
before do
base_suite.add_test_case(test_case_success)
head_suite.add_test_case(test_case_failed)
@@ -105,7 +105,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie still has a failed test case which failed in base' do
+ context 'when head suite still has a failed test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_failed)
@@ -120,7 +120,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
- context 'when head sutie has a success test case which failed in base' do
+ context 'when head suite has a success test case which failed in base' do
before do
base_suite.add_test_case(test_case_failed)
head_suite.add_test_case(test_case_success)
@@ -347,4 +347,128 @@ RSpec.describe Gitlab::Ci::Reports::TestSuiteComparer do
end
end
end
+
+ describe '#limited_tests' do
+ subject(:limited_tests) { comparer.limited_tests }
+
+ context 'limits amount of tests returned' do
+ before do
+ stub_const("#{described_class}::DEFAULT_MAX_TESTS", 2)
+ stub_const("#{described_class}::DEFAULT_MIN_TESTS", 1)
+ end
+
+ context 'prefers new over existing and resolved' do
+ before do
+ 3.times { add_new_failure }
+ 3.times { add_new_error }
+ 3.times { add_existing_failure }
+ 3.times { add_existing_error }
+ 3.times { add_resolved_failure }
+ 3.times { add_resolved_error }
+ end
+
+ it 'returns 2 of each new category, and 1 of each resolved and existing' do
+ expect(limited_tests.new_failures.count).to eq(2)
+ expect(limited_tests.new_errors.count).to eq(2)
+ expect(limited_tests.existing_failures.count).to eq(1)
+ expect(limited_tests.existing_errors.count).to eq(1)
+ expect(limited_tests.resolved_failures.count).to eq(1)
+ expect(limited_tests.resolved_errors.count).to eq(1)
+ end
+
+ it 'does not affect the overall count' do
+ expect(summary).to include(total: 18, resolved: 6, failed: 6, errored: 6)
+ end
+ end
+
+ context 'prefers existing over resolved' do
+ before do
+ 3.times { add_existing_failure }
+ 3.times { add_existing_error }
+ 3.times { add_resolved_failure }
+ 3.times { add_resolved_error }
+ end
+
+ it 'returns 2 of each existing category, and 1 of each resolved' do
+ expect(limited_tests.new_failures.count).to eq(0)
+ expect(limited_tests.new_errors.count).to eq(0)
+ expect(limited_tests.existing_failures.count).to eq(2)
+ expect(limited_tests.existing_errors.count).to eq(2)
+ expect(limited_tests.resolved_failures.count).to eq(1)
+ expect(limited_tests.resolved_errors.count).to eq(1)
+ end
+
+ it 'does not affect the overall count' do
+ expect(summary).to include(total: 12, resolved: 6, failed: 3, errored: 3)
+ end
+ end
+
+ context 'limits amount of resolved' do
+ before do
+ 3.times { add_resolved_failure }
+ 3.times { add_resolved_error }
+ end
+
+ it 'returns 2 of each resolved category' do
+ expect(limited_tests.new_failures.count).to eq(0)
+ expect(limited_tests.new_errors.count).to eq(0)
+ expect(limited_tests.existing_failures.count).to eq(0)
+ expect(limited_tests.existing_errors.count).to eq(0)
+ expect(limited_tests.resolved_failures.count).to eq(2)
+ expect(limited_tests.resolved_errors.count).to eq(2)
+ end
+
+ it 'does not affect the overall count' do
+ expect(summary).to include(total: 6, resolved: 6, failed: 0, errored: 0)
+ end
+ end
+ end
+
+ def summary
+ {
+ total: comparer.total_count,
+ resolved: comparer.resolved_count,
+ failed: comparer.failed_count,
+ errored: comparer.error_count
+ }
+ end
+
+ def add_new_failure
+ failed_case = create_test_case_rspec_failed(SecureRandom.hex)
+ head_suite.add_test_case(failed_case)
+ end
+
+ def add_new_error
+ error_case = create_test_case_rspec_error(SecureRandom.hex)
+ head_suite.add_test_case(error_case)
+ end
+
+ def add_existing_failure
+ failed_case = create_test_case_rspec_failed(SecureRandom.hex)
+ base_suite.add_test_case(failed_case)
+ head_suite.add_test_case(failed_case)
+ end
+
+ def add_existing_error
+ error_case = create_test_case_rspec_error(SecureRandom.hex)
+ base_suite.add_test_case(error_case)
+ head_suite.add_test_case(error_case)
+ end
+
+ def add_resolved_failure
+ case_name = SecureRandom.hex
+ failed_case = create_test_case_java_failed(case_name)
+ success_case = create_test_case_java_success(case_name)
+ base_suite.add_test_case(failed_case)
+ head_suite.add_test_case(success_case)
+ end
+
+ def add_resolved_error
+ case_name = SecureRandom.hex
+ error_case = create_test_case_java_error(case_name)
+ success_case = create_test_case_java_success(case_name)
+ base_suite.add_test_case(error_case)
+ head_suite.add_test_case(success_case)
+ end
+ end
end
diff --git a/spec/lib/gitlab/ci/reports/test_suite_spec.rb b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
index 50d1595da73..1d6b39a7831 100644
--- a/spec/lib/gitlab/ci/reports/test_suite_spec.rb
+++ b/spec/lib/gitlab/ci/reports/test_suite_spec.rb
@@ -91,7 +91,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuite do
subject { test_suite.with_attachment! }
context 'when test cases do not contain an attachment' do
- let(:test_case) { build(:test_case, :failed)}
+ let(:test_case) { build(:report_test_case, :failed)}
before do
test_suite.add_test_case(test_case)
@@ -103,7 +103,7 @@ RSpec.describe Gitlab::Ci::Reports::TestSuite do
end
context 'when test cases contain an attachment' do
- let(:test_case_with_attachment) { build(:test_case, :failed_with_attachment)}
+ let(:test_case_with_attachment) { build(:report_test_case, :failed_with_attachment)}
before do
test_suite.add_test_case(test_case_with_attachment)
diff --git a/spec/lib/gitlab/ci/runner_instructions_spec.rb b/spec/lib/gitlab/ci/runner_instructions_spec.rb
index 32ee2ceb040..d1020026fe6 100644
--- a/spec/lib/gitlab/ci/runner_instructions_spec.rb
+++ b/spec/lib/gitlab/ci/runner_instructions_spec.rb
@@ -75,6 +75,13 @@ RSpec.describe Gitlab::Ci::RunnerInstructions do
with_them do
let(:params) { { os: os, arch: arch } }
+ around do |example|
+ # puma in production does not run from Rails.root, ensure file loading does not assume this
+ Dir.chdir(Rails.root.join('tmp').to_s) do
+ example.run
+ end
+ end
+
it 'returns string containing correct params' do
result = subject.install_script
diff --git a/spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb
new file mode 100644
index 00000000000..4be92e8608e
--- /dev/null
+++ b/spec/lib/gitlab/ci/templates/AWS/deploy_ecs_gitlab_ci_yaml_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Deploy-ECS.gitlab-ci.yml' do
+ subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('AWS/Deploy-ECS') }
+
+ describe 'the created pipeline' do
+ let_it_be(:user) { create(:admin) }
+ let(:default_branch) { 'master' }
+ let(:pipeline_branch) { default_branch }
+ let(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) }
+ let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) }
+ let(:pipeline) { service.execute!(:push) }
+ let(:build_names) { pipeline.builds.pluck(:name) }
+ let(:platform_target) { 'ECS' }
+
+ before do
+ create(:ci_variable, project: project, key: 'AUTO_DEVOPS_PLATFORM_TARGET', value: platform_target)
+ 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
+
+ shared_examples 'no pipeline yaml error' do
+ it 'does not have any error' do
+ expect(pipeline.has_yaml_errors?).to be_falsey
+ end
+ end
+
+ it_behaves_like 'no pipeline yaml error'
+
+ it 'creates the expected jobs' do
+ expect(build_names).to include('production_ecs')
+ end
+
+ context 'when running a pipeline for a branch' do
+ let(:pipeline_branch) { 'test_branch' }
+
+ before do
+ project.repository.create_branch(pipeline_branch)
+ end
+
+ it_behaves_like 'no pipeline yaml error'
+
+ it 'creates the expected jobs' do
+ expect(build_names).to include('review_ecs', 'stop_review_ecs')
+ end
+
+ context 'when deploying to ECS Fargate' do
+ let(:platform_target) { 'FARGATE' }
+
+ it 'creates the expected jobs' do
+ expect(build_names).to include('review_fargate', 'stop_review_fargate')
+ 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 4d90e7ca9e6..793df55f45d 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
@@ -94,14 +94,14 @@ RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do
end
it 'creates an ECS deployment job for review only' do
- expect(review_prod_build_names).to contain_exactly('review_ecs')
+ expect(review_prod_build_names).to contain_exactly('review_ecs', 'stop_review_ecs')
end
context 'with FARGATE as a launch type' do
let(:platform_value) { 'FARGATE' }
it 'creates an FARGATE deployment job for review only' do
- expect(review_prod_build_names).to contain_exactly('review_fargate')
+ expect(review_prod_build_names).to contain_exactly('review_fargate', 'stop_review_fargate')
end
end
end
@@ -122,6 +122,15 @@ RSpec.describe 'Auto-DevOps.gitlab-ci.yml' do
end
end
end
+
+ context 'when the platform target is EC2' do
+ let(:platform_value) { 'EC2' }
+
+ it 'contains the build_artifact job, not the build job' do
+ expect(build_names).to include('build_artifact')
+ expect(build_names).not_to include('build')
+ end
+ end
end
context 'when the project has no active cluster' do
diff --git a/spec/lib/gitlab/ci/variables/collection/item_spec.rb b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
index eba2f29836d..2e43f22830a 100644
--- a/spec/lib/gitlab/ci/variables/collection/item_spec.rb
+++ b/spec/lib/gitlab/ci/variables/collection/item_spec.rb
@@ -15,14 +15,14 @@ RSpec.describe Gitlab::Ci::Variables::Collection::Item do
context 'when unknown keyword is specified' do
it 'raises error' do
expect { described_class.new(key: variable_key, value: 'abc', files: true) }
- .to raise_error ArgumentError, 'unknown keyword: files'
+ .to raise_error ArgumentError, 'unknown keyword: :files'
end
end
context 'when required keywords are not specified' do
it 'raises error' do
expect { described_class.new(key: variable_key) }
- .to raise_error ArgumentError, 'missing keyword: value'
+ .to raise_error ArgumentError, 'missing keyword: :value'
end
end
diff --git a/spec/lib/gitlab/config/entry/simplifiable_spec.rb b/spec/lib/gitlab/config/entry/simplifiable_spec.rb
index 2011587a342..f9088130037 100644
--- a/spec/lib/gitlab/config/entry/simplifiable_spec.rb
+++ b/spec/lib/gitlab/config/entry/simplifiable_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::Config::Entry::Simplifiable do
end
it 'attemps to load a first strategy' do
- expect(first).to receive(:new).with('something', anything)
+ expect(first).to receive(:new).with('something')
entry.new('something')
end
@@ -53,7 +53,7 @@ RSpec.describe Gitlab::Config::Entry::Simplifiable do
end
it 'attemps to load a second strategy' do
- expect(second).to receive(:new).with('test', anything)
+ expect(second).to receive(:new).with('test')
entry.new('test')
end
@@ -68,7 +68,7 @@ RSpec.describe Gitlab::Config::Entry::Simplifiable do
end
it 'instantiates an unknown strategy' do
- expect(unknown).to receive(:new).with('test', anything)
+ expect(unknown).to receive(:new).with('test')
entry.new('test')
end
diff --git a/spec/lib/gitlab/conflict/file_spec.rb b/spec/lib/gitlab/conflict/file_spec.rb
index 80bd517ec92..0de944d3f8a 100644
--- a/spec/lib/gitlab/conflict/file_spec.rb
+++ b/spec/lib/gitlab/conflict/file_spec.rb
@@ -93,6 +93,51 @@ RSpec.describe Gitlab::Conflict::File do
end
end
+ describe '#diff_lines_for_serializer' do
+ let(:diff_line_types) { conflict_file.diff_lines_for_serializer.map(&:type) }
+
+ it 'assigns conflict types to the diff lines' do
+ expect(diff_line_types[4]).to eq('conflict_marker')
+ expect(diff_line_types[5..10]).to eq(['conflict_marker_our'] * 6)
+ expect(diff_line_types[11]).to eq('conflict_marker')
+ expect(diff_line_types[12..17]).to eq(['conflict_marker_their'] * 6)
+ expect(diff_line_types[18]).to eq('conflict_marker')
+
+ expect(diff_line_types[19..24]).to eq([nil] * 6)
+
+ expect(diff_line_types[25]).to eq('conflict_marker')
+ expect(diff_line_types[26..27]).to eq(['conflict_marker_our'] * 2)
+ expect(diff_line_types[28]).to eq('conflict_marker')
+ expect(diff_line_types[29..30]).to eq(['conflict_marker_their'] * 2)
+ expect(diff_line_types[31]).to eq('conflict_marker')
+ end
+
+ it 'does not add a match line to the end of the section' do
+ expect(diff_line_types.last).to eq(nil)
+ end
+
+ context 'when there are unchanged trailing lines' do
+ let(:rugged_conflict) { index.conflicts.first }
+ let(:raw_conflict_content) { index.merge_file('files/ruby/popen.rb')[:data] }
+
+ it 'assign conflict types and adds match line to the end of the section' do
+ expect(diff_line_types).to eq([
+ 'match',
+ nil, nil, nil,
+ "conflict_marker",
+ "conflict_marker_our",
+ "conflict_marker",
+ "conflict_marker_their",
+ "conflict_marker_their",
+ "conflict_marker_their",
+ "conflict_marker",
+ nil, nil, nil,
+ "match"
+ ])
+ end
+ end
+ end
+
describe '#sections' do
it 'only inserts match lines when there is a gap between sections' do
conflict_file.sections.each_with_index do |section, i|
diff --git a/spec/lib/gitlab/cycle_analytics/events_spec.rb b/spec/lib/gitlab/cycle_analytics/events_spec.rb
index a31f34d82d7..2c5988f06b2 100644
--- a/spec/lib/gitlab/cycle_analytics/events_spec.rb
+++ b/spec/lib/gitlab/cycle_analytics/events_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'cycle analytics events', :aggregate_failures do
+RSpec.describe 'value stream analytics events', :aggregate_failures do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user, :admin) }
let(:from_date) { 10.days.ago }
diff --git a/spec/lib/gitlab/danger/commit_linter_spec.rb b/spec/lib/gitlab/danger/commit_linter_spec.rb
index 882cede759b..ebfeedba700 100644
--- a/spec/lib/gitlab/danger/commit_linter_spec.rb
+++ b/spec/lib/gitlab/danger/commit_linter_spec.rb
@@ -190,7 +190,9 @@ RSpec.describe Gitlab::Danger::CommitLinter do
[
'[ci skip] A commit message',
'[Ci skip] A commit message',
- '[API] A commit message'
+ '[API] A commit message',
+ 'api: A commit message',
+ 'API: A commit message'
].each do |message|
context "when subject is '#{message}'" do
let(:commit_message) { message }
@@ -207,6 +209,9 @@ RSpec.describe Gitlab::Danger::CommitLinter do
'[ci skip]A commit message',
'[Ci skip] A commit message',
'[ci skip] a commit message',
+ 'API: a commit message',
+ 'API: a commit message',
+ 'api: a commit message',
'! A commit message'
].each do |message|
context "when subject is '#{message}'" do
diff --git a/spec/lib/gitlab/danger/helper_spec.rb b/spec/lib/gitlab/danger/helper_spec.rb
index 509649f08c6..f400641706d 100644
--- a/spec/lib/gitlab/danger/helper_spec.rb
+++ b/spec/lib/gitlab/danger/helper_spec.rb
@@ -236,13 +236,16 @@ RSpec.describe Gitlab::Danger::Helper do
'.gitlab/ci/frontend.gitlab-ci.yml' | %i[frontend engineering_productivity]
- 'app/models/foo' | [:backend]
- 'bin/foo' | [:backend]
- 'config/foo' | [:backend]
- 'lib/foo' | [:backend]
- 'rubocop/foo' | [:backend]
- 'spec/foo' | [:backend]
- 'spec/foo/bar' | [:backend]
+ 'app/models/foo' | [:backend]
+ 'bin/foo' | [:backend]
+ 'config/foo' | [:backend]
+ 'lib/foo' | [:backend]
+ 'rubocop/foo' | [:backend]
+ '.rubocop.yml' | [:backend]
+ '.rubocop_todo.yml' | [:backend]
+ '.rubocop_manual_todo.yml' | [:backend]
+ 'spec/foo' | [:backend]
+ 'spec/foo/bar' | [:backend]
'ee/app/foo' | [:backend]
'ee/bin/foo' | [:backend]
@@ -278,9 +281,9 @@ RSpec.describe Gitlab::Danger::Helper do
'scripts/foo' | [:engineering_productivity]
'lib/gitlab/danger/foo' | [:engineering_productivity]
'ee/lib/gitlab/danger/foo' | [:engineering_productivity]
- '.overcommit.yml.example' | [:engineering_productivity]
+ 'lefthook.yml' | [:engineering_productivity]
'.editorconfig' | [:engineering_productivity]
- 'tooling/overcommit/foo' | [:engineering_productivity]
+ 'tooling/bin/find_foss_tests' | [:engineering_productivity]
'.codeclimate.yml' | [:engineering_productivity]
'.gitlab/CODEOWNERS' | [:engineering_productivity]
@@ -312,6 +315,8 @@ RSpec.describe Gitlab::Danger::Helper do
'db/fixtures/foo.rb' | [:backend]
'ee/db/fixtures/foo.rb' | [:backend]
+ 'doc/api/graphql/reference/gitlab_schema.graphql' | [:backend]
+ 'doc/api/graphql/reference/gitlab_schema.json' | [:backend]
'qa/foo' | [:qa]
'ee/qa/foo' | [:qa]
diff --git a/spec/lib/gitlab/data_builder/feature_flag_spec.rb b/spec/lib/gitlab/data_builder/feature_flag_spec.rb
new file mode 100644
index 00000000000..75511fcf9f5
--- /dev/null
+++ b/spec/lib/gitlab/data_builder/feature_flag_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::DataBuilder::FeatureFlag do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ describe '.build' do
+ let(:data) { described_class.build(feature_flag, user) }
+
+ it { expect(data).to be_a(Hash) }
+ it { expect(data[:object_kind]).to eq('feature_flag') }
+
+ it 'contains the correct object attributes' do
+ object_attributes = data[:object_attributes]
+
+ expect(object_attributes[:id]).to eq(feature_flag.id)
+ expect(object_attributes[:name]).to eq(feature_flag.name)
+ expect(object_attributes[:description]).to eq(feature_flag.description)
+ expect(object_attributes[:active]).to eq(feature_flag.active)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/batch_count_spec.rb b/spec/lib/gitlab/database/batch_count_spec.rb
index 31a8b4afa03..a1cc759e011 100644
--- a/spec/lib/gitlab/database/batch_count_spec.rb
+++ b/spec/lib/gitlab/database/batch_count_spec.rb
@@ -141,6 +141,29 @@ RSpec.describe Gitlab::Database::BatchCount do
described_class.batch_count(model)
end
+ it 'does not use BETWEEN to define the range' do
+ batch_size = Gitlab::Database::BatchCounter::MIN_REQUIRED_BATCH_SIZE + 1
+ issue = nil
+
+ travel_to(Date.tomorrow) do
+ issue = create(:issue) # created_at: 00:00:00
+ create(:issue, created_at: issue.created_at + batch_size - 0.5) # created_at: 00:20:50.5
+ create(:issue, created_at: issue.created_at + batch_size) # created_at: 00:20:51
+ end
+
+ # When using BETWEEN, the range condition looks like:
+ # Batch 1: WHERE "issues"."created_at" BETWEEN "2020-10-09 00:00:00" AND "2020-10-09 00:20:50"
+ # Batch 2: WHERE "issues"."created_at" BETWEEN "2020-10-09 00:20:51" AND "2020-10-09 00:41:41"
+ # We miss the issue created at 00:20:50.5 because we prevent the batches from overlapping (start..(finish - 1))
+ # See https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_BETWEEN_.28especially_with_timestamps.29
+
+ # When using >= AND <, we eliminate any gaps between batches (start...finish)
+ # This is useful when iterating over a timestamp column
+ # Batch 1: WHERE "issues"."created_at" >= "2020-10-09 00:00:00" AND "issues"."created_at" < "2020-10-09 00:20:51"
+ # Batch 1: WHERE "issues"."created_at" >= "2020-10-09 00:20:51" AND "issues"."created_at" < "2020-10-09 00:41:42"
+ expect(described_class.batch_count(model, :created_at, batch_size: batch_size, start: issue.created_at)).to eq(3)
+ end
+
it_behaves_like 'when a transaction is open' do
subject { described_class.batch_count(model) }
end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index a8edcc5f7e5..ff6e5437559 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -1680,7 +1680,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do
has_internal_id :iid,
scope: :project,
- init: ->(s) { s&.project&.issues&.maximum(:iid) },
+ init: ->(s, _scope) { s&.project&.issues&.maximum(:iid) },
backfill: true,
presence: false
end
diff --git a/spec/lib/gitlab/database/partitioning/replace_table_spec.rb b/spec/lib/gitlab/database/partitioning/replace_table_spec.rb
new file mode 100644
index 00000000000..d47666eeffd
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning/replace_table_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::Partitioning::ReplaceTable, '#perform' do
+ include TableSchemaHelpers
+
+ subject(:replace_table) { described_class.new(original_table, replacement_table, archived_table, 'id').perform }
+
+ let(:original_table) { '_test_original_table' }
+ let(:replacement_table) { '_test_replacement_table' }
+ let(:archived_table) { '_test_archived_table' }
+
+ let(:original_sequence) { "#{original_table}_id_seq" }
+
+ let(:original_primary_key) { "#{original_table}_pkey" }
+ let(:replacement_primary_key) { "#{replacement_table}_pkey" }
+ let(:archived_primary_key) { "#{archived_table}_pkey" }
+
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE #{original_table} (
+ id serial NOT NULL PRIMARY KEY,
+ original_column text NOT NULL,
+ created_at timestamptz NOT NULL);
+
+ CREATE TABLE #{replacement_table} (
+ id int NOT NULL,
+ replacement_column text NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at))
+ PARTITION BY RANGE (created_at);
+ SQL
+ end
+
+ it 'replaces the current table, archiving the old' do
+ expect_table_to_be_replaced { replace_table }
+ end
+
+ it 'transfers the primary key sequence to the replacement table' do
+ expect(sequence_owned_by(original_table, 'id')).to eq(original_sequence)
+ expect(default_expression_for(original_table, 'id')).to eq("nextval('#{original_sequence}'::regclass)")
+
+ expect(sequence_owned_by(replacement_table, 'id')).to be_nil
+ expect(default_expression_for(replacement_table, 'id')).to be_nil
+
+ expect_table_to_be_replaced { replace_table }
+
+ expect(sequence_owned_by(original_table, 'id')).to eq(original_sequence)
+ expect(default_expression_for(original_table, 'id')).to eq("nextval('#{original_sequence}'::regclass)")
+ expect(sequence_owned_by(archived_table, 'id')).to be_nil
+ expect(default_expression_for(archived_table, 'id')).to be_nil
+ end
+
+ it 'renames the primary key constraints to match the new table names' do
+ expect_primary_keys_after_tables([original_table, replacement_table])
+
+ expect_table_to_be_replaced { replace_table }
+
+ expect_primary_keys_after_tables([original_table, archived_table])
+ end
+
+ context 'when the table has partitions' do
+ before do
+ connection.execute(<<~SQL)
+ CREATE TABLE gitlab_partitions_dynamic.#{replacement_table}_202001 PARTITION OF #{replacement_table}
+ FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
+
+ CREATE TABLE gitlab_partitions_dynamic.#{replacement_table}_202002 PARTITION OF #{replacement_table}
+ FOR VALUES FROM ('2020-02-01') TO ('2020-03-01');
+ SQL
+ end
+
+ it 'renames the partitions to match the new table name' do
+ expect(partitions_for_parent_table(original_table).count).to eq(0)
+ expect(partitions_for_parent_table(replacement_table).count).to eq(2)
+
+ expect_table_to_be_replaced { replace_table }
+
+ expect(partitions_for_parent_table(archived_table).count).to eq(0)
+
+ partitions = partitions_for_parent_table(original_table).all
+
+ expect(partitions.size).to eq(2)
+
+ expect(partitions[0]).to have_attributes(
+ identifier: "gitlab_partitions_dynamic.#{original_table}_202001",
+ condition: "FOR VALUES FROM ('2020-01-01 00:00:00+00') TO ('2020-02-01 00:00:00+00')")
+
+ expect(partitions[1]).to have_attributes(
+ identifier: "gitlab_partitions_dynamic.#{original_table}_202002",
+ condition: "FOR VALUES FROM ('2020-02-01 00:00:00+00') TO ('2020-03-01 00:00:00+00')")
+ end
+
+ it 'renames the primary key constraints to match the new partition names' do
+ original_partitions = ["#{replacement_table}_202001", "#{replacement_table}_202002"]
+ expect_primary_keys_after_tables(original_partitions, schema: 'gitlab_partitions_dynamic')
+
+ expect_table_to_be_replaced { replace_table }
+
+ renamed_partitions = ["#{original_table}_202001", "#{original_table}_202002"]
+ expect_primary_keys_after_tables(renamed_partitions, schema: 'gitlab_partitions_dynamic')
+ end
+ end
+
+ def partitions_for_parent_table(table)
+ Gitlab::Database::PostgresPartition.for_parent_table(table)
+ end
+
+ def expect_table_to_be_replaced(&block)
+ super(original_table: original_table, replacement_table: replacement_table, archived_table: archived_table, &block)
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
new file mode 100644
index 00000000000..7f61ff759fc
--- /dev/null
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/index_helpers_spec.rb
@@ -0,0 +1,186 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::IndexHelpers do
+ include TableSchemaHelpers
+
+ let(:migration) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+
+ let(:table_name) { '_test_partitioned_table' }
+ let(:column_name) { 'created_at' }
+ let(:index_name) { '_test_partitioning_index_name' }
+ let(:partition_schema) { 'gitlab_partitions_dynamic' }
+ let(:partition1_identifier) { "#{partition_schema}.#{table_name}_202001" }
+ let(:partition2_identifier) { "#{partition_schema}.#{table_name}_202002" }
+ let(:partition1_index) { "index_#{table_name}_202001_#{column_name}" }
+ let(:partition2_index) { "index_#{table_name}_202002_#{column_name}" }
+
+ before do
+ allow(migration).to receive(:puts)
+
+ connection.execute(<<~SQL)
+ CREATE TABLE #{table_name} (
+ id serial NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE (created_at);
+
+ CREATE TABLE #{partition1_identifier} PARTITION OF #{table_name}
+ FOR VALUES FROM ('2020-01-01') TO ('2020-02-01');
+
+ CREATE TABLE #{partition2_identifier} PARTITION OF #{table_name}
+ FOR VALUES FROM ('2020-02-01') TO ('2020-03-01');
+ SQL
+ end
+
+ describe '#add_concurrent_partitioned_index' do
+ before do
+ allow(migration).to receive(:index_name_exists?).with(table_name, index_name).and_return(false)
+
+ allow(migration).to receive(:generated_index_name).and_return(partition1_index, partition2_index)
+
+ allow(migration).to receive(:with_lock_retries).and_yield
+ end
+
+ context 'when the index does not exist on the parent table' do
+ it 'creates the index on each partition, and the parent table', :aggregate_failures do
+ expect(migration).to receive(:index_name_exists?).with(table_name, index_name).and_return(false)
+
+ expect_add_concurrent_index_and_call_original(partition1_identifier, column_name, partition1_index)
+ expect_add_concurrent_index_and_call_original(partition2_identifier, column_name, partition2_index)
+
+ expect(migration).to receive(:with_lock_retries).ordered.and_yield
+ expect(migration).to receive(:add_index).with(table_name, column_name, name: index_name).ordered.and_call_original
+
+ migration.add_concurrent_partitioned_index(table_name, column_name, name: index_name)
+
+ expect_index_to_exist(partition1_index, schema: partition_schema)
+ expect_index_to_exist(partition2_index, schema: partition_schema)
+ expect_index_to_exist(index_name)
+ end
+
+ def expect_add_concurrent_index_and_call_original(table, column, index)
+ expect(migration).to receive(:add_concurrent_index).ordered.with(table, column, name: index)
+ .and_wrap_original { |_, table, column, options| connection.add_index(table, column, options) }
+ end
+ end
+
+ context 'when the index exists on the parent table' do
+ it 'does not attempt to create any indexes', :aggregate_failures do
+ expect(migration).to receive(:index_name_exists?).with(table_name, index_name).and_return(true)
+
+ expect(migration).not_to receive(:add_concurrent_index)
+ expect(migration).not_to receive(:with_lock_retries)
+ expect(migration).not_to receive(:add_index)
+
+ migration.add_concurrent_partitioned_index(table_name, column_name, name: index_name)
+ end
+ end
+
+ context 'when additional index options are given' do
+ before do
+ connection.execute(<<~SQL)
+ DROP TABLE #{partition2_identifier}
+ SQL
+ end
+
+ it 'forwards them to the index helper methods', :aggregate_failures do
+ expect(migration).to receive(:add_concurrent_index)
+ .with(partition1_identifier, column_name, name: partition1_index, where: 'x > 0', unique: true)
+
+ expect(migration).to receive(:add_index)
+ .with(table_name, column_name, name: index_name, where: 'x > 0', unique: true)
+
+ migration.add_concurrent_partitioned_index(table_name, column_name,
+ name: index_name, where: 'x > 0', unique: true)
+ end
+ end
+
+ context 'when a name argument for the index is not given' do
+ it 'raises an error', :aggregate_failures do
+ expect(migration).not_to receive(:add_concurrent_index)
+ expect(migration).not_to receive(:with_lock_retries)
+ expect(migration).not_to receive(:add_index)
+
+ expect do
+ migration.add_concurrent_partitioned_index(table_name, column_name)
+ end.to raise_error(ArgumentError, /A name is required for indexes added to partitioned tables/)
+ end
+ end
+
+ context 'when the given table is not a partitioned table' do
+ before do
+ allow(Gitlab::Database::PostgresPartitionedTable).to receive(:find_by_name_in_current_schema)
+ .with(table_name).and_return(nil)
+ end
+
+ it 'raises an error', :aggregate_failures do
+ expect(migration).not_to receive(:add_concurrent_index)
+ expect(migration).not_to receive(:with_lock_retries)
+ expect(migration).not_to receive(:add_index)
+
+ expect do
+ migration.add_concurrent_partitioned_index(table_name, column_name, name: index_name)
+ end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/)
+ end
+ end
+ end
+
+ describe '#remove_concurrent_partitioned_index_by_name' do
+ context 'when the index exists' do
+ before do
+ connection.execute(<<~SQL)
+ CREATE INDEX #{partition1_index} ON #{partition1_identifier} (#{column_name});
+ CREATE INDEX #{partition2_index} ON #{partition2_identifier} (#{column_name});
+
+ CREATE INDEX #{index_name} ON #{table_name} (#{column_name});
+ SQL
+ end
+
+ it 'drops the index on the parent table, cascading to all partitions', :aggregate_failures do
+ expect_index_to_exist(partition1_index, schema: partition_schema)
+ expect_index_to_exist(partition2_index, schema: partition_schema)
+ expect_index_to_exist(index_name)
+
+ expect(migration).to receive(:with_lock_retries).ordered.and_yield
+ expect(migration).to receive(:remove_index).with(table_name, name: index_name).ordered.and_call_original
+
+ migration.remove_concurrent_partitioned_index_by_name(table_name, index_name)
+
+ expect_index_not_to_exist(partition1_index, schema: partition_schema)
+ expect_index_not_to_exist(partition2_index, schema: partition_schema)
+ expect_index_not_to_exist(index_name)
+ end
+ end
+
+ context 'when the index does not exist' do
+ it 'does not attempt to drop the index', :aggregate_failures do
+ expect(migration).to receive(:index_name_exists?).with(table_name, index_name).and_return(false)
+
+ expect(migration).not_to receive(:with_lock_retries)
+ expect(migration).not_to receive(:remove_index)
+
+ migration.remove_concurrent_partitioned_index_by_name(table_name, index_name)
+ end
+ end
+
+ context 'when the given table is not a partitioned table' do
+ before do
+ allow(Gitlab::Database::PostgresPartitionedTable).to receive(:find_by_name_in_current_schema)
+ .with(table_name).and_return(nil)
+ end
+
+ it 'raises an error', :aggregate_failures do
+ expect(migration).not_to receive(:with_lock_retries)
+ expect(migration).not_to receive(:remove_index)
+
+ expect do
+ migration.remove_concurrent_partitioned_index_by_name(table_name, index_name)
+ end.to raise_error(ArgumentError, /#{table_name} is not a partitioned table/)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
index 147637cf471..f10ff704c17 100644
--- a/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
+++ b/spec/lib/gitlab/database/partitioning_migration_helpers/table_management_helpers_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHelpers do
include PartitioningHelpers
include TriggerHelpers
+ include TableSchemaHelpers
let(:migration) do
ActiveRecord::Migration.new.extend(described_class)
@@ -629,6 +630,76 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe
end
end
+ describe '#replace_with_partitioned_table' do
+ let(:archived_table) { "#{source_table}_archived" }
+
+ before do
+ migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date
+ end
+
+ it 'replaces the original table with the partitioned table' do
+ expect(table_type(source_table)).to eq('normal')
+ expect(table_type(partitioned_table)).to eq('partitioned')
+ expect(table_type(archived_table)).to be_nil
+
+ expect_table_to_be_replaced { migration.replace_with_partitioned_table(source_table) }
+
+ expect(table_type(source_table)).to eq('partitioned')
+ expect(table_type(archived_table)).to eq('normal')
+ expect(table_type(partitioned_table)).to be_nil
+ end
+
+ it 'moves the trigger from the original table to the new table' do
+ expect_function_to_exist(function_name)
+ expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
+
+ expect_table_to_be_replaced { migration.replace_with_partitioned_table(source_table) }
+
+ expect_function_to_exist(function_name)
+ expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
+ end
+
+ def expect_table_to_be_replaced(&block)
+ super(original_table: source_table, replacement_table: partitioned_table, archived_table: archived_table, &block)
+ end
+ end
+
+ describe '#rollback_replace_with_partitioned_table' do
+ let(:archived_table) { "#{source_table}_archived" }
+
+ before do
+ migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date
+
+ migration.replace_with_partitioned_table source_table
+ end
+
+ it 'replaces the partitioned table with the non-partitioned table' do
+ expect(table_type(source_table)).to eq('partitioned')
+ expect(table_type(archived_table)).to eq('normal')
+ expect(table_type(partitioned_table)).to be_nil
+
+ expect_table_to_be_replaced { migration.rollback_replace_with_partitioned_table(source_table) }
+
+ expect(table_type(source_table)).to eq('normal')
+ expect(table_type(partitioned_table)).to eq('partitioned')
+ expect(table_type(archived_table)).to be_nil
+ end
+
+ it 'moves the trigger from the partitioned table to the non-partitioned table' do
+ expect_function_to_exist(function_name)
+ expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
+
+ expect_table_to_be_replaced { migration.rollback_replace_with_partitioned_table(source_table) }
+
+ expect_function_to_exist(function_name)
+ expect_valid_function_trigger(source_table, trigger_name, function_name, after: %w[delete insert update])
+ end
+
+ def expect_table_to_be_replaced(&block)
+ super(original_table: source_table, replacement_table: archived_table, archived_table: partitioned_table, &block)
+ end
+ end
+
def filter_columns_by_name(columns, names)
columns.reject { |c| names.include?(c.name) }
end
diff --git a/spec/lib/gitlab/database/postgres_index_spec.rb b/spec/lib/gitlab/database/postgres_index_spec.rb
index 1da67a5a6c0..d65b638f7bc 100644
--- a/spec/lib/gitlab/database/postgres_index_spec.rb
+++ b/spec/lib/gitlab/database/postgres_index_spec.rb
@@ -3,9 +3,13 @@
require 'spec_helper'
RSpec.describe Gitlab::Database::PostgresIndex do
+ let(:schema) { 'public' }
+ let(:name) { 'foo_idx' }
+ let(:identifier) { "#{schema}.#{name}" }
+
before do
ActiveRecord::Base.connection.execute(<<~SQL)
- CREATE INDEX foo_idx ON public.users (name);
+ CREATE INDEX #{name} ON public.users (name);
CREATE UNIQUE INDEX bar_key ON public.users (id);
CREATE TABLE example_table (id serial primary key);
@@ -16,19 +20,7 @@ RSpec.describe Gitlab::Database::PostgresIndex do
described_class.by_identifier(name)
end
- describe '.by_identifier' do
- it 'finds the index' do
- expect(find('public.foo_idx')).to be_a(Gitlab::Database::PostgresIndex)
- end
-
- it 'raises an error if not found' do
- expect { find('public.idontexist') }.to raise_error(ActiveRecord::RecordNotFound)
- end
-
- it 'raises ArgumentError if given a non-fully qualified index name' do
- expect { find('foo') }.to raise_error(ArgumentError, /not fully qualified/)
- end
- end
+ it_behaves_like 'a postgres model'
describe '.regular' do
it 'only non-unique indexes' do
@@ -76,7 +68,7 @@ RSpec.describe Gitlab::Database::PostgresIndex do
describe '#valid_index?' do
it 'returns true if the index is invalid' do
- expect(find('public.foo_idx')).to be_valid_index
+ expect(find(identifier)).to be_valid_index
end
it 'returns false if the index is marked as invalid' do
@@ -86,31 +78,13 @@ RSpec.describe Gitlab::Database::PostgresIndex do
WHERE pg_class.relname = 'foo_idx' AND pg_index.indexrelid = pg_class.oid
SQL
- expect(find('public.foo_idx')).not_to be_valid_index
- end
- end
-
- describe '#to_s' do
- it 'returns the index name' do
- expect(find('public.foo_idx').to_s).to eq('foo_idx')
- end
- end
-
- describe '#name' do
- it 'returns the name' do
- expect(find('public.foo_idx').name).to eq('foo_idx')
- end
- end
-
- describe '#schema' do
- it 'returns the index schema' do
- expect(find('public.foo_idx').schema).to eq('public')
+ expect(find(identifier)).not_to be_valid_index
end
end
describe '#definition' do
it 'returns the index definition' do
- expect(find('public.foo_idx').definition).to eq('CREATE INDEX foo_idx ON public.users USING btree (name)')
+ expect(find(identifier).definition).to eq('CREATE INDEX foo_idx ON public.users USING btree (name)')
end
end
end
diff --git a/spec/lib/gitlab/database/postgres_partition_spec.rb b/spec/lib/gitlab/database/postgres_partition_spec.rb
new file mode 100644
index 00000000000..5a44090d5ae
--- /dev/null
+++ b/spec/lib/gitlab/database/postgres_partition_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::PostgresPartition, type: :model do
+ let(:schema) { 'gitlab_partitions_dynamic' }
+ let(:name) { '_test_partition_01' }
+ let(:identifier) { "#{schema}.#{name}" }
+
+ before do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ CREATE TABLE public._test_partitioned_table (
+ id serial NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE(created_at);
+
+ CREATE TABLE #{identifier} PARTITION OF public._test_partitioned_table
+ FOR VALUES FROM ('2020-01-01') to ('2020-02-01');
+ SQL
+ end
+
+ def find(identifier)
+ described_class.by_identifier(identifier)
+ end
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:postgres_partitioned_table).with_primary_key('identifier').with_foreign_key('parent_identifier') }
+ end
+
+ it_behaves_like 'a postgres model'
+
+ describe '.for_parent_table' do
+ let(:second_name) { '_test_partition_02' }
+
+ before do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ CREATE TABLE #{schema}.#{second_name} PARTITION OF public._test_partitioned_table
+ FOR VALUES FROM ('2020-02-01') to ('2020-03-01');
+
+ CREATE TABLE #{schema}._test_other_table (
+ id serial NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE(created_at);
+
+ CREATE TABLE #{schema}._test_other_partition_01 PARTITION OF #{schema}._test_other_table
+ FOR VALUES FROM ('2020-01-01') to ('2020-02-01');
+ SQL
+ end
+
+ it 'returns partitions for the parent table in the current schema' do
+ partitions = described_class.for_parent_table('_test_partitioned_table')
+
+ expect(partitions.count).to eq(2)
+ expect(partitions.pluck(:name)).to eq([name, second_name])
+ end
+
+ it 'does not return partitions for tables not in the current schema' do
+ expect(described_class.for_parent_table('_test_other_table').count).to eq(0)
+ end
+ end
+
+ describe '#parent_identifier' do
+ it 'returns the parent table identifier' do
+ expect(find(identifier).parent_identifier).to eq('public._test_partitioned_table')
+ end
+ end
+
+ describe '#condition' do
+ it 'returns the condition for the partitioned values' do
+ expect(find(identifier).condition).to eq("FOR VALUES FROM ('2020-01-01 00:00:00+00') TO ('2020-02-01 00:00:00+00')")
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb b/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb
new file mode 100644
index 00000000000..21a46f1a0a6
--- /dev/null
+++ b/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb
@@ -0,0 +1,98 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::PostgresPartitionedTable, type: :model do
+ let(:schema) { 'public' }
+ let(:name) { 'foo_range' }
+ let(:identifier) { "#{schema}.#{name}" }
+
+ before do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ CREATE TABLE #{identifier} (
+ id serial NOT NULL,
+ created_at timestamptz NOT NULL,
+ PRIMARY KEY (id, created_at)
+ ) PARTITION BY RANGE(created_at);
+
+ CREATE TABLE public.foo_list (
+ id serial NOT NULL,
+ row_type text NOT NULL,
+ PRIMARY KEY (id, row_type)
+ ) PARTITION BY LIST(row_type);
+
+ CREATE TABLE public.foo_hash (
+ id serial NOT NULL,
+ row_value int NOT NULL,
+ PRIMARY KEY (id, row_value)
+ ) PARTITION BY HASH (row_value);
+ SQL
+ end
+
+ def find(identifier)
+ described_class.by_identifier(identifier)
+ end
+
+ describe 'associations' do
+ it { is_expected.to have_many(:postgres_partitions).with_primary_key('identifier').with_foreign_key('parent_identifier') }
+ end
+
+ it_behaves_like 'a postgres model'
+
+ describe '.find_by_name_in_current_schema' do
+ it 'finds the partitioned tables in the current schema by name', :aggregate_failures do
+ partitioned_table = described_class.find_by_name_in_current_schema(name)
+
+ expect(partitioned_table).not_to be_nil
+ expect(partitioned_table.identifier).to eq(identifier)
+ end
+
+ it 'does not find partitioned tables in a different schema' do
+ ActiveRecord::Base.connection.execute(<<~SQL)
+ ALTER TABLE #{identifier} SET SCHEMA gitlab_partitions_dynamic
+ SQL
+
+ expect(described_class.find_by_name_in_current_schema(name)).to be_nil
+ end
+ end
+
+ describe '#dynamic?' do
+ it 'returns true for tables partitioned by range' do
+ expect(find('public.foo_range')).to be_dynamic
+ end
+
+ it 'returns true for tables partitioned by list' do
+ expect(find('public.foo_list')).to be_dynamic
+ end
+
+ it 'returns false for tables partitioned by hash' do
+ expect(find('public.foo_hash')).not_to be_dynamic
+ end
+ end
+
+ describe '#static?' do
+ it 'returns false for tables partitioned by range' do
+ expect(find('public.foo_range')).not_to be_static
+ end
+
+ it 'returns false for tables partitioned by list' do
+ expect(find('public.foo_list')).not_to be_static
+ end
+
+ it 'returns true for tables partitioned by hash' do
+ expect(find('public.foo_hash')).to be_static
+ end
+ end
+
+ describe '#strategy' do
+ it 'returns the partitioning strategy' do
+ expect(find(identifier).strategy).to eq('range')
+ end
+ end
+
+ describe '#key_columns' do
+ it 'returns the partitioning key columns' do
+ expect(find(identifier).key_columns).to match_array(['created_at'])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/reindexing_spec.rb b/spec/lib/gitlab/database/reindexing_spec.rb
index 86b3c029944..359e0597f4e 100644
--- a/spec/lib/gitlab/database/reindexing_spec.rb
+++ b/spec/lib/gitlab/database/reindexing_spec.rb
@@ -24,7 +24,7 @@ RSpec.describe Gitlab::Database::Reindexing do
it 'retrieves regular indexes that are no left-overs from previous runs' do
result = double
- expect(Gitlab::Database::PostgresIndex).to receive_message_chain('regular.not_match.not_match').with(no_args).with('^tmp_reindex_').with('^old_reindex_').and_return(result)
+ expect(Gitlab::Database::PostgresIndex).to receive_message_chain('regular.where.not_match.not_match').with(no_args).with('NOT expression').with('^tmp_reindex_').with('^old_reindex_').and_return(result)
expect(subject).to eq(result)
end
diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
index a38fe2c51ca..2ebfb054a96 100644
--- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
+++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb
@@ -11,13 +11,13 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
end
let(:email_raw) { email_fixture('emails/service_desk.eml') }
- let_it_be(:namespace) { create(:namespace, name: "email") }
+ let_it_be(:group) { create(:group, :private, name: "email") }
let(:expected_description) do
"Service desk stuff!\n\n```\na = b\n```\n\n`/label ~label1`\n`/assign @user1`\n`/close`\n![image](uploads/image.png)"
end
context 'service desk is enabled for the project' do
- let_it_be(:project) { create(:project, :repository, :public, namespace: namespace, path: 'test', service_desk_enabled: true) }
+ let_it_be(:project) { create(:project, :repository, :private, group: group, path: 'test', service_desk_enabled: true) }
before do
allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(true)
@@ -101,6 +101,18 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
expect(issue.milestone).to eq(milestone)
end
+ it 'applies group labels using quick actions' do
+ group_label = create(:group_label, group: project.group, title: 'label2')
+ file_content = %(Text from template \n/label ~#{group_label.title}"")
+ set_template_file('with_group_labels', file_content)
+
+ receiver.execute
+
+ issue = Issue.last
+ expect(issue.description).to include('Text from template')
+ expect(issue.label_ids).to include(group_label.id)
+ end
+
it 'redacts quick actions present on user email body' do
set_template_file('service_desk1', 'text from template')
@@ -289,7 +301,8 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do
end
context 'service desk is disabled for the project' do
- let(:project) { create(:project, :public, namespace: namespace, path: 'test', service_desk_enabled: false) }
+ let(:group) { create(:group)}
+ let(:project) { create(:project, :public, group: group, path: 'test', service_desk_enabled: false) }
it 'bounces the email' do
expect { receiver.execute }.to raise_error(Gitlab::Email::ProcessingError)
diff --git a/spec/lib/gitlab/error_tracking_spec.rb b/spec/lib/gitlab/error_tracking_spec.rb
index 2cc9ff36c99..68a46b11487 100644
--- a/spec/lib/gitlab/error_tracking_spec.rb
+++ b/spec/lib/gitlab/error_tracking_spec.rb
@@ -198,47 +198,39 @@ RSpec.describe Gitlab::ErrorTracking do
end
describe '.track_exception' do
- it 'calls Raven.capture_exception' do
- expected_extras = {
- some_other_info: 'info',
- issue_url: issue_url
- }
+ let(:extra) { { issue_url: issue_url, some_other_info: 'info' } }
- expected_tags = {
- correlation_id: 'cid'
- }
+ subject(:track_exception) { described_class.track_exception(exception, extra) }
- expect(Raven).to receive(:capture_exception)
- .with(exception,
- tags: a_hash_including(expected_tags),
- extra: a_hash_including(expected_extras))
-
- described_class.track_exception(
- exception,
- issue_url: issue_url,
- some_other_info: 'info'
- )
+ before do
+ allow(Raven).to receive(:capture_exception).and_call_original
+ allow(Gitlab::ErrorTracking::Logger).to receive(:error)
+ end
+
+ it 'calls Raven.capture_exception' do
+ track_exception
+
+ expect(Raven).to have_received(:capture_exception)
+ .with(exception,
+ tags: a_hash_including(correlation_id: 'cid'),
+ extra: a_hash_including(some_other_info: 'info', issue_url: issue_url))
end
it 'calls Gitlab::ErrorTracking::Logger.error with formatted payload' do
- expect(Gitlab::ErrorTracking::Logger).to receive(:error)
- .with(a_hash_including(*expected_payload_includes))
+ track_exception
- described_class.track_exception(
- exception,
- issue_url: issue_url,
- some_other_info: 'info'
- )
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error)
+ .with(a_hash_including(*expected_payload_includes))
end
context 'with filterable parameters' do
let(:extra) { { test: 1, my_token: 'test' } }
it 'filters parameters' do
- expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(
- hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' }))
+ track_exception
- described_class.track_exception(exception, extra)
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error)
+ .with(hash_including({ 'extra.test' => 1, 'extra.my_token' => '[FILTERED]' }))
end
end
@@ -247,44 +239,58 @@ RSpec.describe Gitlab::ErrorTracking do
let(:exception) { double(message: 'bang!', sentry_extra_data: extra_info, backtrace: caller) }
it 'includes the extra data from the exception in the tracking information' do
- expect(Raven).to receive(:capture_exception)
- .with(exception, a_hash_including(extra: a_hash_including(extra_info)))
+ track_exception
- described_class.track_exception(exception)
+ expect(Raven).to have_received(:capture_exception)
+ .with(exception, a_hash_including(extra: a_hash_including(extra_info)))
end
end
context 'the exception implements :sentry_extra_data, which returns nil' do
let(:exception) { double(message: 'bang!', sentry_extra_data: nil, backtrace: caller) }
+ let(:extra) { { issue_url: issue_url } }
it 'just includes the other extra info' do
- extra_info = { issue_url: issue_url }
- expect(Raven).to receive(:capture_exception)
- .with(exception, a_hash_including(extra: a_hash_including(extra_info)))
+ track_exception
- described_class.track_exception(exception, extra_info)
+ expect(Raven).to have_received(:capture_exception)
+ .with(exception, a_hash_including(extra: a_hash_including(extra)))
end
end
context 'with sidekiq args' do
- it 'ensures extra.sidekiq.args is a string' do
- extra = { sidekiq: { 'class' => 'PostReceive', 'args' => [1, { 'id' => 2, 'name' => 'hello' }, 'some-value', 'another-value'] } }
+ context 'when the args does not have anything sensitive' do
+ let(:extra) { { sidekiq: { 'class' => 'PostReceive', 'args' => [1, { 'id' => 2, 'name' => 'hello' }, 'some-value', 'another-value'] } } }
- expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(
- hash_including({ 'extra.sidekiq' => { 'class' => 'PostReceive', 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value'] } }))
+ it 'ensures extra.sidekiq.args is a string' do
+ track_exception
- described_class.track_exception(exception, extra)
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(
+ hash_including({ 'extra.sidekiq' => { 'class' => 'PostReceive', 'args' => ['1', '{"id"=>2, "name"=>"hello"}', 'some-value', 'another-value'] } }))
+ end
end
- it 'filters sensitive arguments before sending' do
- extra = { sidekiq: { 'class' => 'UnknownWorker', 'args' => ['sensitive string', 1, 2] } }
+ context 'when the args has sensitive information' do
+ let(:extra) { { sidekiq: { 'class' => 'UnknownWorker', 'args' => ['sensitive string', 1, 2] } } }
+
+ it 'filters sensitive arguments before sending' do
+ track_exception
+
+ expect(sentry_event.dig('extra', 'sidekiq', 'args')).to eq(['[FILTERED]', 1, 2])
+ expect(Gitlab::ErrorTracking::Logger).to have_received(:error).with(
+ hash_including('extra.sidekiq' => { 'class' => 'UnknownWorker', 'args' => ['[FILTERED]', '1', '2'] }))
+ end
+ end
+ end
- expect(Gitlab::ErrorTracking::Logger).to receive(:error).with(
- hash_including('extra.sidekiq' => { 'class' => 'UnknownWorker', 'args' => ['[FILTERED]', '1', '2'] }))
+ context 'when the error is kind of an `ActiveRecord::StatementInvalid`' do
+ let(:exception) { ActiveRecord::StatementInvalid.new(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = 1 AND "users"."foo" = $1') }
- described_class.track_exception(exception, extra)
+ it 'injects the normalized sql query into extra' do
+ track_exception
- expect(sentry_event.dig('extra', 'sidekiq', 'args')).to eq(['[FILTERED]', 1, 2])
+ expect(Raven).to have_received(:capture_exception)
+ .with(exception, a_hash_including(extra: a_hash_including(sql: 'SELECT "users".* FROM "users" WHERE "users"."id" = $2 AND "users"."foo" = $1')))
end
end
end
diff --git a/spec/lib/gitlab/etag_caching/middleware_spec.rb b/spec/lib/gitlab/etag_caching/middleware_spec.rb
index 361b2329e15..3122a3b1c07 100644
--- a/spec/lib/gitlab/etag_caching/middleware_spec.rb
+++ b/spec/lib/gitlab/etag_caching/middleware_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::EtagCaching::Middleware do
+RSpec.describe Gitlab::EtagCaching::Middleware, :clean_gitlab_redis_shared_state do
let(:app) { double(:app) }
let(:middleware) { described_class.new(app) }
let(:app_status_code) { 200 }
@@ -10,6 +10,17 @@ RSpec.describe Gitlab::EtagCaching::Middleware do
let(:enabled_path) { '/gitlab-org/gitlab-foss/noteable/issue/1/notes' }
let(:endpoint) { 'issue_notes' }
+ describe '.skip!' do
+ it 'sets the skip header on the response' do
+ rsp = ActionDispatch::Response.new
+ rsp.set_header('Anything', 'Else')
+
+ described_class.skip!(rsp)
+
+ expect(rsp.headers.to_h).to eq(described_class::SKIP_HEADER_KEY => '1', 'Anything' => 'Else')
+ end
+ end
+
context 'when ETag caching is not enabled for current route' do
let(:path) { '/gitlab-org/gitlab-foss/tree/master/noteable/issue/1/notes' }
@@ -17,10 +28,12 @@ RSpec.describe Gitlab::EtagCaching::Middleware do
mock_app_response
end
- it 'does not add ETag header' do
+ it 'does not add ETag headers' do
_, headers, _ = middleware.call(build_request(path, if_none_match))
expect(headers['ETag']).to be_nil
+ expect(headers['X-Gitlab-From-Cache']).to be_nil
+ expect(headers[::Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER]).to be_nil
end
it 'passes status code from app' do
@@ -68,13 +81,35 @@ RSpec.describe Gitlab::EtagCaching::Middleware do
mock_value_in_store('123')
end
- it 'returns this value as header' do
+ it 'returns the correct headers' do
_, headers, _ = middleware.call(build_request(path, if_none_match))
expect(headers['ETag']).to eq 'W/"123"'
end
end
+ context 'when the matching route requests that the ETag is skipped' do
+ let(:path) { enabled_path }
+ let(:app) do
+ proc do |_env|
+ response = ActionDispatch::Response.new
+
+ described_class.skip!(response)
+
+ [200, response.headers.to_h, '']
+ end
+ end
+
+ it 'returns the correct headers' do
+ expect(app).to receive(:call).and_call_original
+
+ _, headers, _ = middleware.call(build_request(path, if_none_match))
+
+ expect(headers).not_to have_key('ETag')
+ expect(headers).not_to have_key(described_class::SKIP_HEADER_KEY)
+ end
+ end
+
shared_examples 'sends a process_action.action_controller notification' do |status_code|
let(:expected_items) do
{
@@ -126,6 +161,13 @@ RSpec.describe Gitlab::EtagCaching::Middleware do
expect(status).to eq 304
end
+ it 'sets correct headers' do
+ _, headers, _ = middleware.call(build_request(path, if_none_match))
+
+ expect(headers).to include('X-Gitlab-From-Cache' => 'true',
+ ::Gitlab::Metrics::RequestsRackMiddleware::FEATURE_CATEGORY_HEADER => 'issue_tracking')
+ end
+
it_behaves_like 'sends a process_action.action_controller notification', 304
it 'returns empty body' do
diff --git a/spec/lib/gitlab/etag_caching/router_spec.rb b/spec/lib/gitlab/etag_caching/router_spec.rb
index 3e939e588ad..dbd9cc230f1 100644
--- a/spec/lib/gitlab/etag_caching/router_spec.rb
+++ b/spec/lib/gitlab/etag_caching/router_spec.rb
@@ -127,4 +127,12 @@ RSpec.describe Gitlab::EtagCaching::Router do
expect(result).to be_present
expect(result.name).to eq 'project_pipeline'
end
+
+ it 'has a valid feature category for every route', :aggregate_failures do
+ feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).to_set
+
+ described_class::ROUTES.each do |route|
+ expect(feature_categories).to include(route.feature_category), "#{route.name} has a category of #{route.feature_category}, which is not valid"
+ end
+ end
end
diff --git a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
index 40669f06371..8bf06bcebe2 100644
--- a/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
+++ b/spec/lib/gitlab/exclusive_lease_helpers_spec.rb
@@ -88,7 +88,7 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
let(:options) { { retries: 0 } }
it 'never sleeps' do
- expect(class_instance).not_to receive(:sleep)
+ expect_any_instance_of(Gitlab::ExclusiveLeaseHelpers::SleepingLock).not_to receive(:sleep)
expect { subject }.to raise_error('Failed to obtain a lock')
end
@@ -98,7 +98,7 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
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_any_instance_of(Gitlab::ExclusiveLeaseHelpers::SleepingLock).to receive(:sleep).with(0.05.seconds).once
expect { subject }.to raise_error('Failed to obtain a lock')
end
@@ -108,8 +108,8 @@ RSpec.describe Gitlab::ExclusiveLeaseHelpers, :clean_gitlab_redis_shared_state d
let(:options) { { retries: 2, sleep_sec: ->(num) { 0.1 + num } } }
it 'receives the specified argument' do
- 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_any_instance_of(Gitlab::ExclusiveLeaseHelpers::SleepingLock).to receive(:sleep).with(1.1.seconds).once
+ expect_any_instance_of(Gitlab::ExclusiveLeaseHelpers::SleepingLock).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/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
new file mode 100644
index 00000000000..2fe3d36daf7
--- /dev/null
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -0,0 +1,438 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
+ before do
+ stub_const('Gitlab::Experimentation::EXPERIMENTS', {
+ backwards_compatible_test_experiment: {
+ environment: environment,
+ tracking_category: 'Team',
+ use_backwards_compatible_subject_index: true
+ },
+ test_experiment: {
+ environment: environment,
+ tracking_category: 'Team'
+ }
+ }
+ )
+
+ Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage)
+ Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage)
+ end
+
+ let(:environment) { Rails.env.test? }
+ let(:enabled_percentage) { 10 }
+
+ controller(ApplicationController) do
+ include Gitlab::Experimentation::ControllerConcern
+
+ def index
+ head :ok
+ end
+ end
+
+ describe '#set_experimentation_subject_id_cookie' do
+ let(:do_not_track) { nil }
+ let(:cookie) { cookies.permanent.signed[:experimentation_subject_id] }
+
+ before do
+ request.headers['DNT'] = do_not_track if do_not_track.present?
+
+ get :index
+ end
+
+ context 'cookie is present' do
+ before do
+ cookies[:experimentation_subject_id] = 'test'
+ end
+
+ it 'does not change the cookie' do
+ expect(cookies[:experimentation_subject_id]).to eq 'test'
+ end
+ end
+
+ context 'cookie is not present' do
+ it 'sets a permanent signed cookie' do
+ expect(cookie).to be_present
+ end
+
+ context 'DNT: 0' do
+ let(:do_not_track) { '0' }
+
+ it 'sets a permanent signed cookie' do
+ expect(cookie).to be_present
+ end
+ end
+
+ context 'DNT: 1' do
+ let(:do_not_track) { '1' }
+
+ it 'does nothing' do
+ expect(cookie).not_to be_present
+ end
+ end
+ end
+ end
+
+ describe '#push_frontend_experiment' do
+ it 'pushes an experiment to the frontend' do
+ gon = instance_double('gon')
+ experiments = { experiments: { 'myExperiment' => true } }
+
+ stub_experiment_for_user(my_experiment: true)
+ allow(controller).to receive(:gon).and_return(gon)
+
+ expect(gon).to receive(:push).with(experiments, true)
+
+ controller.push_frontend_experiment(:my_experiment)
+ end
+ end
+
+ describe '#experiment_enabled?' do
+ def check_experiment(exp_key = :test_experiment)
+ controller.experiment_enabled?(exp_key)
+ end
+
+ subject { check_experiment }
+
+ context 'cookie is not present' do
+ it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of nil' do
+ expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, nil)
+ check_experiment
+ end
+ end
+
+ context 'cookie is present' do
+ using RSpec::Parameterized::TableSyntax
+
+ before do
+ cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234'
+ get :index
+ end
+
+ where(:experiment_key, :index_value) do
+ :test_experiment | 40 # Zlib.crc32('test_experimentabcd-1234') % 100 = 40
+ :backwards_compatible_test_experiment | 76 # 'abcd1234'.hex % 100 = 76
+ end
+
+ with_them do
+ it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and the calculated experimentation_subject_index based on the uuid' do
+ expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(experiment_key, index_value)
+ check_experiment(experiment_key)
+ end
+ end
+ end
+
+ it 'returns true when DNT: 0 is set in the request' do
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
+ controller.request.headers['DNT'] = '0'
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns false when DNT: 1 is set in the request' do
+ allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
+ controller.request.headers['DNT'] = '1'
+
+ is_expected.to be_falsy
+ end
+
+ describe 'URL parameter to force enable experiment' do
+ it 'returns true unconditionally' do
+ get :index, params: { force_experiment: :test_experiment }
+
+ is_expected.to be_truthy
+ end
+ end
+ end
+
+ describe '#track_experiment_event', :snowplow do
+ context 'when the experiment is enabled' do
+ before do
+ stub_experiment(test_experiment: true)
+ end
+
+ context 'the user is part of the experimental group' do
+ before do
+ stub_experiment_for_user(test_experiment: true)
+ end
+
+ it 'tracks the event with the right parameters' do
+ controller.track_experiment_event(:test_experiment, 'start', 1)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'experimental_group',
+ value: 1
+ )
+ end
+ end
+
+ context 'the user is part of the control group' do
+ before do
+ stub_experiment_for_user(test_experiment: false)
+ end
+
+ it 'tracks the event with the right parameters' do
+ controller.track_experiment_event(:test_experiment, 'start', 1)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 1
+ )
+ end
+ end
+
+ context 'do not track is disabled' do
+ before do
+ request.headers['DNT'] = '0'
+ end
+
+ it 'does track the event' do
+ controller.track_experiment_event(:test_experiment, 'start', 1)
+
+ expect_snowplow_event(
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 1
+ )
+ end
+ end
+
+ context 'do not track enabled' do
+ before do
+ request.headers['DNT'] = '1'
+ end
+
+ it 'does not track the event' do
+ controller.track_experiment_event(:test_experiment, 'start', 1)
+
+ expect_no_snowplow_event
+ end
+ end
+ end
+
+ context 'when the experiment is disabled' do
+ before do
+ stub_experiment(test_experiment: false)
+ end
+
+ it 'does not track the event' do
+ controller.track_experiment_event(:test_experiment, 'start')
+
+ expect_no_snowplow_event
+ end
+ end
+ end
+
+ describe '#frontend_experimentation_tracking_data' do
+ context 'when the experiment is enabled' do
+ before do
+ stub_experiment(test_experiment: true)
+ end
+
+ context 'the user is part of the experimental group' do
+ before do
+ stub_experiment_for_user(test_experiment: true)
+ end
+
+ it 'pushes the right parameters to gon' do
+ controller.frontend_experimentation_tracking_data(:test_experiment, 'start', 'team_id')
+ expect(Gon.tracking_data).to eq(
+ {
+ category: 'Team',
+ action: 'start',
+ property: 'experimental_group',
+ value: 'team_id'
+ }
+ )
+ end
+ end
+
+ context 'the user is part of the control group' do
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
+ end
+ end
+
+ it 'pushes the right parameters to gon' do
+ controller.frontend_experimentation_tracking_data(:test_experiment, 'start', 'team_id')
+ expect(Gon.tracking_data).to eq(
+ {
+ category: 'Team',
+ action: 'start',
+ property: 'control_group',
+ value: 'team_id'
+ }
+ )
+ end
+
+ it 'does not send nil value to gon' do
+ controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
+ expect(Gon.tracking_data).to eq(
+ {
+ category: 'Team',
+ action: 'start',
+ property: 'control_group'
+ }
+ )
+ end
+ end
+
+ context 'do not track disabled' do
+ before do
+ request.headers['DNT'] = '0'
+ end
+
+ it 'pushes the right parameters to gon' do
+ controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
+
+ expect(Gon.tracking_data).to eq(
+ {
+ category: 'Team',
+ action: 'start',
+ property: 'control_group'
+ }
+ )
+ end
+ end
+
+ context 'do not track enabled' do
+ before do
+ request.headers['DNT'] = '1'
+ end
+
+ it 'does not push data to gon' do
+ controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
+
+ expect(Gon.method_defined?(:tracking_data)).to be_falsey
+ end
+ end
+ end
+
+ context 'when the experiment is disabled' do
+ before do
+ stub_experiment(test_experiment: false)
+ end
+
+ it 'does not push data to gon' do
+ expect(Gon.method_defined?(:tracking_data)).to be_falsey
+ controller.track_experiment_event(:test_experiment, 'start')
+ end
+ end
+ end
+
+ describe '#record_experiment_user' do
+ let(:user) { build(:user) }
+
+ context 'when the experiment is enabled' do
+ before do
+ stub_experiment(test_experiment: true)
+ allow(controller).to receive(:current_user).and_return(user)
+ end
+
+ context 'the user is part of the experimental group' do
+ before do
+ stub_experiment_for_user(test_experiment: true)
+ end
+
+ it 'calls add_user on the Experiment model' do
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+
+ context 'the user is part of the control group' do
+ before do
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
+ end
+ end
+
+ it 'calls add_user on the Experiment model' do
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+ end
+
+ context 'when the experiment is disabled' do
+ before do
+ stub_experiment(test_experiment: false)
+ allow(controller).to receive(:current_user).and_return(user)
+ end
+
+ it 'does not call add_user on the Experiment model' do
+ expect(::Experiment).not_to receive(:add_user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+
+ context 'when there is no current_user' do
+ before do
+ stub_experiment(test_experiment: true)
+ end
+
+ it 'does not call add_user on the Experiment model' do
+ expect(::Experiment).not_to receive(:add_user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+
+ context 'do not track' do
+ before do
+ allow(controller).to receive(:current_user).and_return(user)
+ allow_next_instance_of(described_class) do |instance|
+ allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
+ end
+ end
+
+ context 'is disabled' do
+ before do
+ request.headers['DNT'] = '0'
+ end
+
+ it 'calls add_user on the Experiment model' do
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+
+ context 'is enabled' do
+ before do
+ request.headers['DNT'] = '1'
+ end
+
+ it 'does not call add_user on the Experiment model' do
+ expect(::Experiment).not_to receive(:add_user)
+
+ controller.record_experiment_user(:test_experiment)
+ end
+ end
+ end
+ end
+
+ describe '#experiment_tracking_category_and_group' do
+ let_it_be(:experiment_key) { :test_something }
+
+ subject { controller.experiment_tracking_category_and_group(experiment_key) }
+
+ it 'returns a string with the experiment tracking category & group joined with a ":"' do
+ expect(controller).to receive(:tracking_category).with(experiment_key).and_return('Experiment::Category')
+ expect(controller).to receive(:tracking_group).with(experiment_key, '_group').and_return('experimental_group')
+
+ expect(subject).to eq('Experiment::Category:experimental_group')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/experimentation/group_types_spec.rb b/spec/lib/gitlab/experimentation/group_types_spec.rb
new file mode 100644
index 00000000000..599ad08f706
--- /dev/null
+++ b/spec/lib/gitlab/experimentation/group_types_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Experimentation::GroupTypes do
+ it 'defines a GROUP_CONTROL constant' do
+ expect(described_class.const_defined?(:GROUP_CONTROL)).to be_truthy
+ end
+
+ it 'defines a GROUP_EXPERIMENTAL constant' do
+ expect(described_class.const_defined?(:GROUP_EXPERIMENTAL)).to be_truthy
+ end
+end
diff --git a/spec/lib/gitlab/experimentation_spec.rb b/spec/lib/gitlab/experimentation_spec.rb
index e93593d348f..ebf98a0151f 100644
--- a/spec/lib/gitlab/experimentation_spec.rb
+++ b/spec/lib/gitlab/experimentation_spec.rb
@@ -2,423 +2,54 @@
require 'spec_helper'
+# As each associated, backwards-compatible experiment gets cleaned up and removed from the EXPERIMENTS list, its key will also get removed from this list. Once the list here is empty, we can remove the backwards compatibility code altogether.
+# Originally created as part of https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45733 for https://gitlab.com/gitlab-org/gitlab/-/issues/270858.
+RSpec.describe Gitlab::Experimentation::EXPERIMENTS do
+ it 'temporarily ensures we know what experiments exist for backwards compatibility' do
+ expected_experiment_keys = [
+ :onboarding_issues,
+ :ci_notification_dot,
+ :upgrade_link_in_user_menu_a,
+ :invite_members_version_a,
+ :invite_members_version_b,
+ :invite_members_empty_group_version_a,
+ :new_create_project_ui,
+ :contact_sales_btn_in_app,
+ :customize_homepage,
+ :invite_email,
+ :invitation_reminders,
+ :group_only_trials,
+ :default_to_issues_board
+ ]
+
+ backwards_compatible_experiment_keys = described_class.filter { |_, v| v[:use_backwards_compatible_subject_index] }.keys
+
+ expect(backwards_compatible_experiment_keys).not_to be_empty, "Oh, hey! Let's clean up that :use_backwards_compatible_subject_index stuff now :D"
+ expect(backwards_compatible_experiment_keys).to match(expected_experiment_keys)
+ end
+end
+
RSpec.describe Gitlab::Experimentation, :snowplow do
before do
stub_const('Gitlab::Experimentation::EXPERIMENTS', {
+ backwards_compatible_test_experiment: {
+ environment: environment,
+ tracking_category: 'Team',
+ use_backwards_compatible_subject_index: true
+ },
test_experiment: {
environment: environment,
tracking_category: 'Team'
}
})
+ Feature.enable_percentage_of_time(:backwards_compatible_test_experiment_experiment_percentage, enabled_percentage)
Feature.enable_percentage_of_time(:test_experiment_experiment_percentage, enabled_percentage)
end
let(:environment) { Rails.env.test? }
let(:enabled_percentage) { 10 }
- describe Gitlab::Experimentation::ControllerConcern, type: :controller do
- controller(ApplicationController) do
- include Gitlab::Experimentation::ControllerConcern
-
- def index
- head :ok
- end
- end
-
- describe '#set_experimentation_subject_id_cookie' do
- let(:do_not_track) { nil }
- let(:cookie) { cookies.permanent.signed[:experimentation_subject_id] }
-
- before do
- request.headers['DNT'] = do_not_track if do_not_track.present?
-
- get :index
- end
-
- context 'cookie is present' do
- before do
- cookies[:experimentation_subject_id] = 'test'
- end
-
- it 'does not change the cookie' do
- expect(cookies[:experimentation_subject_id]).to eq 'test'
- end
- end
-
- context 'cookie is not present' do
- it 'sets a permanent signed cookie' do
- expect(cookie).to be_present
- end
-
- context 'DNT: 0' do
- let(:do_not_Track) { '0' }
-
- it 'sets a permanent signed cookie' do
- expect(cookie).to be_present
- end
- end
-
- context 'DNT: 1' do
- let(:do_not_track) { '1' }
-
- it 'does nothing' do
- expect(cookie).not_to be_present
- end
- end
- end
- end
-
- describe '#push_frontend_experiment' do
- it 'pushes an experiment to the frontend' do
- gon = instance_double('gon')
- experiments = { experiments: { 'myExperiment' => true } }
-
- stub_experiment_for_user(my_experiment: true)
- allow(controller).to receive(:gon).and_return(gon)
-
- expect(gon).to receive(:push).with(experiments, true)
-
- controller.push_frontend_experiment(:my_experiment)
- end
- end
-
- describe '#experiment_enabled?' do
- subject { controller.experiment_enabled?(:test_experiment) }
-
- context 'cookie is not present' do
- it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of nil' do
- expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, nil)
- controller.experiment_enabled?(:test_experiment)
- end
- end
-
- context 'cookie is present' do
- before do
- cookies.permanent.signed[:experimentation_subject_id] = 'abcd-1234'
- get :index
- end
-
- it 'calls Gitlab::Experimentation.enabled_for_value? with the name of the experiment and an experimentation_subject_index of the modulo 100 of the hex value of the uuid' do
- # 'abcd1234'.hex % 100 = 76
- expect(Gitlab::Experimentation).to receive(:enabled_for_value?).with(:test_experiment, 76)
- controller.experiment_enabled?(:test_experiment)
- end
- end
-
- it 'returns true when DNT: 0 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
- controller.request.headers['DNT'] = '0'
-
- is_expected.to be_truthy
- end
-
- it 'returns false when DNT: 1 is set in the request' do
- allow(Gitlab::Experimentation).to receive(:enabled_for_value?) { true }
- controller.request.headers['DNT'] = '1'
-
- is_expected.to be_falsy
- end
-
- describe 'URL parameter to force enable experiment' do
- it 'returns true unconditionally' do
- get :index, params: { force_experiment: :test_experiment }
-
- is_expected.to be_truthy
- end
- end
- end
-
- describe '#track_experiment_event' do
- context 'when the experiment is enabled' do
- before do
- stub_experiment(test_experiment: true)
- end
-
- context 'the user is part of the experimental group' do
- before do
- stub_experiment_for_user(test_experiment: true)
- end
-
- it 'tracks the event with the right parameters' do
- controller.track_experiment_event(:test_experiment, 'start', 1)
-
- expect_snowplow_event(
- category: 'Team',
- action: 'start',
- property: 'experimental_group',
- value: 1
- )
- end
- end
-
- context 'the user is part of the control group' do
- before do
- stub_experiment_for_user(test_experiment: false)
- end
-
- it 'tracks the event with the right parameters' do
- controller.track_experiment_event(:test_experiment, 'start', 1)
-
- expect_snowplow_event(
- category: 'Team',
- action: 'start',
- property: 'control_group',
- value: 1
- )
- end
- end
-
- context 'do not track is disabled' do
- before do
- request.headers['DNT'] = '0'
- end
-
- it 'does track the event' do
- controller.track_experiment_event(:test_experiment, 'start', 1)
-
- expect_snowplow_event(
- category: 'Team',
- action: 'start',
- property: 'control_group',
- value: 1
- )
- end
- end
-
- context 'do not track enabled' do
- before do
- request.headers['DNT'] = '1'
- end
-
- it 'does not track the event' do
- controller.track_experiment_event(:test_experiment, 'start', 1)
-
- expect_no_snowplow_event
- end
- end
- end
-
- context 'when the experiment is disabled' do
- before do
- stub_experiment(test_experiment: false)
- end
-
- it 'does not track the event' do
- controller.track_experiment_event(:test_experiment, 'start')
-
- expect_no_snowplow_event
- end
- end
- end
-
- describe '#frontend_experimentation_tracking_data' do
- context 'when the experiment is enabled' do
- before do
- stub_experiment(test_experiment: true)
- end
-
- context 'the user is part of the experimental group' do
- before do
- stub_experiment_for_user(test_experiment: true)
- end
-
- it 'pushes the right parameters to gon' do
- controller.frontend_experimentation_tracking_data(:test_experiment, 'start', 'team_id')
- expect(Gon.tracking_data).to eq(
- {
- category: 'Team',
- action: 'start',
- property: 'experimental_group',
- value: 'team_id'
- }
- )
- end
- end
-
- context 'the user is part of the control group' do
- before do
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
- end
- end
-
- it 'pushes the right parameters to gon' do
- controller.frontend_experimentation_tracking_data(:test_experiment, 'start', 'team_id')
- expect(Gon.tracking_data).to eq(
- {
- category: 'Team',
- action: 'start',
- property: 'control_group',
- value: 'team_id'
- }
- )
- end
-
- it 'does not send nil value to gon' do
- controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
- expect(Gon.tracking_data).to eq(
- {
- category: 'Team',
- action: 'start',
- property: 'control_group'
- }
- )
- end
- end
-
- context 'do not track disabled' do
- before do
- request.headers['DNT'] = '0'
- end
-
- it 'pushes the right parameters to gon' do
- controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
-
- expect(Gon.tracking_data).to eq(
- {
- category: 'Team',
- action: 'start',
- property: 'control_group'
- }
- )
- end
- end
-
- context 'do not track enabled' do
- before do
- request.headers['DNT'] = '1'
- end
-
- it 'does not push data to gon' do
- controller.frontend_experimentation_tracking_data(:test_experiment, 'start')
-
- expect(Gon.method_defined?(:tracking_data)).to be_falsey
- end
- end
- end
-
- context 'when the experiment is disabled' do
- before do
- stub_experiment(test_experiment: false)
- end
-
- it 'does not push data to gon' do
- expect(Gon.method_defined?(:tracking_data)).to be_falsey
- controller.track_experiment_event(:test_experiment, 'start')
- end
- end
- end
-
- describe '#record_experiment_user' do
- let(:user) { build(:user) }
-
- context 'when the experiment is enabled' do
- before do
- stub_experiment(test_experiment: true)
- allow(controller).to receive(:current_user).and_return(user)
- end
-
- context 'the user is part of the experimental group' do
- before do
- stub_experiment_for_user(test_experiment: true)
- end
-
- it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
-
- context 'the user is part of the control group' do
- before do
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
- end
- end
-
- it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
- end
-
- context 'when the experiment is disabled' do
- before do
- stub_experiment(test_experiment: false)
- allow(controller).to receive(:current_user).and_return(user)
- end
-
- it 'does not call add_user on the Experiment model' do
- expect(::Experiment).not_to receive(:add_user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
-
- context 'when there is no current_user' do
- before do
- stub_experiment(test_experiment: true)
- end
-
- it 'does not call add_user on the Experiment model' do
- expect(::Experiment).not_to receive(:add_user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
-
- context 'do not track' do
- before do
- allow(controller).to receive(:current_user).and_return(user)
- allow_next_instance_of(described_class) do |instance|
- allow(instance).to receive(:experiment_enabled?).with(:test_experiment).and_return(false)
- end
- end
-
- context 'is disabled' do
- before do
- request.headers['DNT'] = '0'
- end
-
- it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
-
- context 'is enabled' do
- before do
- request.headers['DNT'] = '1'
- end
-
- it 'does not call add_user on the Experiment model' do
- expect(::Experiment).not_to receive(:add_user)
-
- controller.record_experiment_user(:test_experiment)
- end
- end
- end
- end
-
- describe '#experiment_tracking_category_and_group' do
- let_it_be(:experiment_key) { :test_something }
-
- subject { controller.experiment_tracking_category_and_group(experiment_key) }
-
- it 'returns a string with the experiment tracking category & group joined with a ":"' do
- expect(controller).to receive(:tracking_category).with(experiment_key).and_return('Experiment::Category')
- expect(controller).to receive(:tracking_group).with(experiment_key, '_group').and_return('experimental_group')
-
- expect(subject).to eq('Experiment::Category:experimental_group')
- end
- end
- end
-
describe '.enabled?' do
subject { described_class.enabled?(:test_experiment) }
@@ -442,6 +73,14 @@ RSpec.describe Gitlab::Experimentation, :snowplow do
let(:environment) { ::Gitlab.com? }
it { is_expected.to be_falsey }
+
+ it 'ensures the typically less expensive environment is checked before the more expensive call to database for Feature' do
+ expect_next_instance_of(described_class::Experiment) do |experiment|
+ expect(experiment).not_to receive(:enabled?)
+ end
+
+ subject
+ end
end
end
diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb
index 980a52bb61e..d4174a34433 100644
--- a/spec/lib/gitlab/git/diff_spec.rb
+++ b/spec/lib/gitlab/git/diff_spec.rb
@@ -301,19 +301,19 @@ EOT
describe '#too_large?' do
it 'returns true for a diff that is too large' do
- diff = described_class.new(diff: 'a' * 204800)
+ diff = described_class.new({ diff: 'a' * 204800 })
expect(diff.too_large?).to eq(true)
end
it 'returns false for a diff that is small enough' do
- diff = described_class.new(diff: 'a')
+ diff = described_class.new({ diff: 'a' })
expect(diff.too_large?).to eq(false)
end
it 'returns true for a diff that was explicitly marked as being too large' do
- diff = described_class.new(diff: 'a')
+ diff = described_class.new({ diff: 'a' })
diff.too_large!
@@ -323,19 +323,19 @@ EOT
describe '#collapsed?' do
it 'returns false by default even on quite big diff' do
- diff = described_class.new(diff: 'a' * 20480)
+ diff = described_class.new({ diff: 'a' * 20480 })
expect(diff).not_to be_collapsed
end
it 'returns false by default for a diff that is small enough' do
- diff = described_class.new(diff: 'a')
+ diff = described_class.new({ diff: 'a' })
expect(diff).not_to be_collapsed
end
it 'returns true for a diff that was explicitly marked as being collapsed' do
- diff = described_class.new(diff: 'a')
+ diff = described_class.new({ diff: 'a' })
diff.collapse!
@@ -359,7 +359,7 @@ EOT
describe '#collapse!' do
it 'prunes the diff' do
- diff = described_class.new(diff: "foo\nbar")
+ diff = described_class.new({ diff: "foo\nbar" })
diff.collapse!
diff --git a/spec/lib/gitlab/git_access_snippet_spec.rb b/spec/lib/gitlab/git_access_snippet_spec.rb
index 8c481cdee08..f5d8758a78a 100644
--- a/spec/lib/gitlab/git_access_snippet_spec.rb
+++ b/spec/lib/gitlab/git_access_snippet_spec.rb
@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Gitlab::GitAccessSnippet do
include ProjectHelpers
include TermsHelper
+ include AdminModeHelper
include_context 'ProjectPolicyTable context'
using RSpec::Parameterized::TableSyntax
@@ -207,12 +208,13 @@ RSpec.describe Gitlab::GitAccessSnippet do
let(:snippet) { create(:personal_snippet, snippet_level, :repository) }
let(:user) { membership == :author ? snippet.author : create_user_from_membership(nil, membership) }
- where(:snippet_level, :membership, :_expected_count) do
+ where(:snippet_level, :membership, :admin_mode, :_expected_count) do
permission_table_for_personal_snippet_access
end
with_them do
it "respects accessibility" do
+ enable_admin_mode!(user) if admin_mode
error_class = described_class::ForbiddenError
if Ability.allowed?(user, :update_snippet, snippet)
diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
index f977fe1638f..b09bd9dff1b 100644
--- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
safe_max_files: 100,
safe_max_lines: 5000,
safe_max_bytes: 512000,
- max_patch_bytes: 102400
+ max_patch_bytes: 204800
)
expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash))
@@ -57,7 +57,7 @@ RSpec.describe Gitlab::GitalyClient::CommitService do
safe_max_files: 100,
safe_max_lines: 5000,
safe_max_bytes: 512000,
- max_patch_bytes: 102400
+ max_patch_bytes: 204800
)
expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash))
diff --git a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
index c7ea0a95596..f810a5c15a5 100644
--- a/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
+++ b/spec/lib/gitlab/gitaly_client/repository_service_spec.rb
@@ -38,7 +38,7 @@ RSpec.describe Gitlab::GitalyClient::RepositoryService do
.with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash))
.and_return(double(:garbage_collect_response))
- client.garbage_collect(true)
+ client.garbage_collect(true, prune: true)
end
end
diff --git a/spec/lib/gitlab/github_import/client_spec.rb b/spec/lib/gitlab/github_import/client_spec.rb
index 5f6ab42d0d2..bc734644d29 100644
--- a/spec/lib/gitlab/github_import/client_spec.rb
+++ b/spec/lib/gitlab/github_import/client_spec.rb
@@ -203,16 +203,40 @@ RSpec.describe Gitlab::GithubImport::Client do
describe '#requests_remaining?' do
let(:client) { described_class.new('foo') }
- it 'returns true if enough requests remain' do
- expect(client).to receive(:remaining_requests).and_return(9000)
+ context 'when default requests limit is set' do
+ before do
+ allow(client).to receive(:requests_limit).and_return(5000)
+ end
+
+ it 'returns true if enough requests remain' do
+ expect(client).to receive(:remaining_requests).and_return(9000)
+
+ expect(client.requests_remaining?).to eq(true)
+ end
+
+ it 'returns false if not enough requests remain' do
+ expect(client).to receive(:remaining_requests).and_return(1)
- expect(client.requests_remaining?).to eq(true)
+ expect(client.requests_remaining?).to eq(false)
+ end
end
- it 'returns false if not enough requests remain' do
- expect(client).to receive(:remaining_requests).and_return(1)
+ context 'when search requests limit is set' do
+ before do
+ allow(client).to receive(:requests_limit).and_return(described_class::SEARCH_MAX_REQUESTS_PER_MINUTE)
+ end
+
+ it 'returns true if enough requests remain' do
+ expect(client).to receive(:remaining_requests).and_return(described_class::SEARCH_RATE_LIMIT_THRESHOLD + 1)
+
+ expect(client.requests_remaining?).to eq(true)
+ end
+
+ it 'returns false if not enough requests remain' do
+ expect(client).to receive(:remaining_requests).and_return(described_class::SEARCH_RATE_LIMIT_THRESHOLD - 1)
- expect(client.requests_remaining?).to eq(false)
+ expect(client.requests_remaining?).to eq(false)
+ end
end
end
@@ -262,6 +286,16 @@ RSpec.describe Gitlab::GithubImport::Client do
end
end
+ describe '#requests_limit' do
+ it 'returns requests limit' do
+ client = described_class.new('foo')
+ rate_limit = double(limit: 1)
+
+ expect(client.octokit).to receive(:rate_limit).and_return(rate_limit)
+ expect(client.requests_limit).to eq(1)
+ end
+ end
+
describe '#rate_limit_resets_in' do
it 'returns the number of seconds after which the rate limit is reset' do
client = described_class.new('foo')
@@ -299,6 +333,32 @@ RSpec.describe Gitlab::GithubImport::Client do
end
end
+ describe '#web_endpoint' do
+ let(:client) { described_class.new('foo') }
+
+ context 'without a custom endpoint configured in Omniauth' do
+ it 'returns the default web endpoint' do
+ expect(client)
+ .to receive(:custom_api_endpoint)
+ .and_return(nil)
+
+ expect(client.web_endpoint).to eq('https://github.com')
+ end
+ end
+
+ context 'with a custom endpoint configured in Omniauth' do
+ it 'returns the custom endpoint' do
+ endpoint = 'https://github.kittens.com'
+
+ expect(client)
+ .to receive(:custom_api_endpoint)
+ .and_return(endpoint)
+
+ expect(client.web_endpoint).to eq(endpoint)
+ end
+ end
+ end
+
describe '#custom_api_endpoint' do
let(:client) { described_class.new('foo') }
@@ -391,4 +451,61 @@ RSpec.describe Gitlab::GithubImport::Client do
expect(client.rate_limiting_enabled?).to eq(false)
end
end
+
+ describe 'search' do
+ let(:client) { described_class.new('foo') }
+ let(:user) { double(:user, login: 'user') }
+ let(:org1) { double(:org, login: 'org1') }
+ let(:org2) { double(:org, login: 'org2') }
+ let(:repo1) { double(:repo, full_name: 'repo1') }
+ let(:repo2) { double(:repo, full_name: 'repo2') }
+
+ before do
+ allow(client)
+ .to receive(:each_object)
+ .with(:repos, nil, { affiliation: 'collaborator' })
+ .and_return([repo1, repo2].to_enum)
+
+ allow(client)
+ .to receive(:each_object)
+ .with(:organizations)
+ .and_return([org1, org2].to_enum)
+
+ allow(client.octokit).to receive(:user).and_return(user)
+ end
+
+ describe '#search_repos_by_name' do
+ it 'searches for repositories based on name' do
+ expected_search_query = 'test in:name is:public,private user:user repo:repo1 repo:repo2 org:org1 org:org2'
+
+ expect(client).to receive(:each_page).with(:search_repositories, expected_search_query)
+
+ client.search_repos_by_name('test')
+ end
+ end
+
+ describe '#search_query' do
+ it 'returns base search query' do
+ result = client.search_query(str: 'test', type: :test, include_collaborations: false, include_orgs: false)
+
+ expect(result).to eq('test in:test is:public,private user:user')
+ end
+
+ context 'when include_collaborations is true' do
+ it 'returns search query including users' do
+ result = client.search_query(str: 'test', type: :test, include_collaborations: true, include_orgs: false)
+
+ expect(result).to eq('test in:test is:public,private user:user repo:repo1 repo:repo2')
+ end
+ end
+
+ context 'when include_orgs is true' do
+ it 'returns search query including users' do
+ result = client.search_query(str: 'test', type: :test, include_collaborations: false, include_orgs: true)
+
+ expect(result).to eq('test in:test is:public,private user:user org:org1 org:org2')
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
index add554992f1..188c56ae81f 100644
--- a/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/lfs_object_importer_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectImporter do
}
end
- let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) }
+ let(:lfs_download_object) { LfsDownloadObject.new(**lfs_attributes) }
let(:github_lfs_object) { Gitlab::GithubImport::Representation::LfsObject.new(lfs_attributes) }
let(:importer) { described_class.new(github_lfs_object, project, nil) }
diff --git a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
index 1f7b14661c2..6188ba8ec3f 100644
--- a/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/importer/lfs_objects_importer_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectsImporter do
}
end
- let(:lfs_download_object) { LfsDownloadObject.new(lfs_attributes) }
+ let(:lfs_download_object) { LfsDownloadObject.new(**lfs_attributes) }
describe '#parallel?' do
it 'returns true when running in parallel mode' do
diff --git a/spec/lib/gitlab/github_import/sequential_importer_spec.rb b/spec/lib/gitlab/github_import/sequential_importer_spec.rb
index fe13fcd2568..a5e89049ed9 100644
--- a/spec/lib/gitlab/github_import/sequential_importer_spec.rb
+++ b/spec/lib/gitlab/github_import/sequential_importer_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Gitlab::GithubImport::SequentialImporter do
describe '#execute' do
it 'imports a project in sequence' do
repository = double(:repository)
- project = double(:project, id: 1, repository: repository)
+ project = double(:project, id: 1, repository: repository, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git')
importer = described_class.new(project, token: 'foo')
expect_next_instance_of(Gitlab::GithubImport::Importer::RepositoryImporter) do |instance|
diff --git a/spec/lib/gitlab/github_import_spec.rb b/spec/lib/gitlab/github_import_spec.rb
index 1a690b81d2b..3129da64809 100644
--- a/spec/lib/gitlab/github_import_spec.rb
+++ b/spec/lib/gitlab/github_import_spec.rb
@@ -3,13 +3,13 @@
require 'spec_helper'
RSpec.describe Gitlab::GithubImport do
- let(:project) { double(:project) }
+ context 'github.com' do
+ let(:project) { double(:project, import_url: 'http://t0ken@github.com/user/repo.git') }
- describe '.new_client_for' do
it 'returns a new Client with a custom token' do
expect(described_class::Client)
.to receive(:new)
- .with('123', parallel: true)
+ .with('123', host: nil, parallel: true)
described_class.new_client_for(project, token: '123')
end
@@ -23,18 +23,57 @@ RSpec.describe Gitlab::GithubImport do
expect(described_class::Client)
.to receive(:new)
- .with('123', parallel: true)
+ .with('123', host: nil, parallel: true)
described_class.new_client_for(project)
end
+
+ it 'returns the ID of the ghost user', :clean_gitlab_redis_cache do
+ expect(described_class.ghost_user_id).to eq(User.ghost.id)
+ end
+
+ it 'caches the ghost user ID', :clean_gitlab_redis_cache do
+ expect(Gitlab::Cache::Import::Caching)
+ .to receive(:write)
+ .once
+ .and_call_original
+
+ 2.times do
+ described_class.ghost_user_id
+ end
+ end
end
- describe '.ghost_user_id', :clean_gitlab_redis_cache do
- it 'returns the ID of the ghost user' do
+ context 'GitHub Enterprise' do
+ let(:project) { double(:project, import_url: 'http://t0ken@github.another-domain.com/repo-org/repo.git') }
+
+ it 'returns a new Client with a custom token' do
+ expect(described_class::Client)
+ .to receive(:new)
+ .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true)
+
+ described_class.new_client_for(project, token: '123')
+ end
+
+ it 'returns a new Client with a token stored in the import data' do
+ import_data = double(:import_data, credentials: { user: '123' })
+
+ expect(project)
+ .to receive(:import_data)
+ .and_return(import_data)
+
+ expect(described_class::Client)
+ .to receive(:new)
+ .with('123', host: 'http://github.another-domain.com/api/v3', parallel: true)
+
+ described_class.new_client_for(project)
+ end
+
+ it 'returns the ID of the ghost user', :clean_gitlab_redis_cache do
expect(described_class.ghost_user_id).to eq(User.ghost.id)
end
- it 'caches the ghost user ID' do
+ it 'caches the ghost user ID', :clean_gitlab_redis_cache do
expect(Gitlab::Cache::Import::Caching)
.to receive(:write)
.once
@@ -44,5 +83,9 @@ RSpec.describe Gitlab::GithubImport do
described_class.ghost_user_id
end
end
+
+ it 'formats the import url' do
+ expect(described_class.formatted_import_url(project)).to eq('http://github.another-domain.com/api/v3')
+ end
end
end
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 91299de0751..487b19a98e0 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
@@ -15,7 +15,8 @@ RSpec.describe Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp do
path: '/api/v4/projects/1',
params: {
'description': '[FILTERED]',
- 'name': 'gitlab test'
+ 'name': 'gitlab test',
+ 'int': 42
},
host: 'localhost',
remote_ip: '127.0.0.1',
@@ -44,7 +45,8 @@ RSpec.describe Gitlab::GrapeLogging::Formatters::LogrageWithTimestamp do
expect(params).to eq([
{ 'key' => 'description', 'value' => '[FILTERED]' },
- { 'key' => 'name', 'value' => 'gitlab test' }
+ { 'key' => 'name', 'value' => 'gitlab test' },
+ { 'key' => 'int', 'value' => 42 }
])
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 7576523ce52..c88506899cd 100644
--- a/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb
+++ b/spec/lib/gitlab/graphql/authorize/authorize_field_service_spec.rb
@@ -27,13 +27,17 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
end
end
+ def resolve
+ service.authorized_resolve[type_instance, {}, context]
+ end
+
subject(:service) { described_class.new(field) }
describe '#authorized_resolve' do
let_it_be(:current_user) { build(:user) }
let_it_be(:presented_object) { 'presented object' }
let_it_be(:query_type) { GraphQL::ObjectType.new }
- let_it_be(:schema) { GraphQL::Schema.define(query: query_type, mutation: nil)}
+ let_it_be(:schema) { GitlabSchema }
let_it_be(:query) { GraphQL::Query.new(schema, document: nil, context: {}, variables: {}) }
let_it_be(:context) { GraphQL::Query::Context.new(query: query, values: { current_user: current_user }, object: nil) }
@@ -41,125 +45,201 @@ RSpec.describe Gitlab::Graphql::Authorize::AuthorizeFieldService do
let(:type_instance) { type_class.authorized_new(presented_object, context) }
let(:field) { type_class.fields['testField'].to_graphql }
- subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) }
+ subject(:resolved) { ::Gitlab::Graphql::Lazy.force(resolve) }
- context 'scalar types' do
- shared_examples 'checking permissions on the presented object' do
- it 'checks the abilities on the object being presented and returns the value' do
- expected_permissions.each do |permission|
- spy_ability_check_for(permission, presented_object, passed: true)
- end
+ context 'reading the field of a lazy value' do
+ let(:ability) { :read_field }
+ let(:presented_object) { lazy_upcase('a') }
+ let(:type_class) { type_with_field(GraphQL::STRING_TYPE, ability) }
- expect(resolved).to eq('Resolved value')
+ let(:upcaser) do
+ Module.new do
+ def self.upcase(strs)
+ strs.map(&:upcase)
+ end
end
+ end
- it 'returns nil if the value was not authorized' do
- allow(Ability).to receive(:allowed?).and_return false
-
- expect(resolved).to be_nil
+ def lazy_upcase(str)
+ ::BatchLoader::GraphQL.for(str).batch do |strs, found|
+ strs.zip(upcaser.upcase(strs)).each { |s, us| found[s, us] }
end
end
- context 'when the field is a built-in scalar type' do
- let(:type_class) { type_with_field(GraphQL::STRING_TYPE, :read_field) }
- let(:expected_permissions) { [:read_field] }
+ it 'does not run authorizations until we force the resolved value' do
+ expect(Ability).not_to receive(:allowed?)
- it_behaves_like 'checking permissions on the presented object'
+ expect(resolve).to respond_to(:force)
end
- context 'when the field is a list of scalar types' do
- let(:type_class) { type_with_field([GraphQL::STRING_TYPE], :read_field) }
- let(:expected_permissions) { [:read_field] }
+ it 'runs authorizations when we force the resolved value' do
+ spy_ability_check_for(ability, 'A')
- it_behaves_like 'checking permissions on the presented object'
+ expect(resolved).to eq('Resolved value')
end
- context 'when the field is sub-classed scalar type' do
- let(:type_class) { type_with_field(Types::TimeType, :read_field) }
- let(:expected_permissions) { [:read_field] }
+ it 'redacts values that fail the permissions check' do
+ spy_ability_check_for(ability, 'A', passed: false)
- it_behaves_like 'checking permissions on the presented object'
+ expect(resolved).to be_nil
end
- context 'when the field is a list of sub-classed scalar types' do
- let(:type_class) { type_with_field([Types::TimeType], :read_field) }
- let(:expected_permissions) { [:read_field] }
+ context 'we batch two calls' do
+ def resolve(value)
+ instance = type_class.authorized_new(lazy_upcase(value), context)
+ service.authorized_resolve[instance, {}, context]
+ end
- it_behaves_like 'checking permissions on the presented object'
- end
- end
+ it 'batches resolution, but authorizes each object separately' do
+ expect(upcaser).to receive(:upcase).once.and_call_original
+ spy_ability_check_for(:read_field, 'A', passed: true)
+ spy_ability_check_for(:read_field, 'B', passed: false)
+ spy_ability_check_for(:read_field, 'C', passed: true)
- context 'when the field is a connection' do
- context 'when it resolves to nil' do
- let(:type_class) { type_with_field(Types::QueryType.connection_type, :read_field, nil) }
+ a = resolve('a')
+ b = resolve('b')
+ c = resolve('c')
- it 'does not fail when authorizing' do
- expect(resolved).to be_nil
+ expect(a.force).to be_present
+ expect(b.force).to be_nil
+ expect(c.force).to be_present
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') }
+ shared_examples 'authorizing fields' do
+ context 'scalar types' do
+ shared_examples 'checking permissions on the presented object' do
+ it 'checks the abilities on the object being presented and returns the value' do
+ expected_permissions.each do |permission|
+ spy_ability_check_for(permission, presented_object, passed: true)
+ end
- let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) }
- let(:type_instance) { type_class.authorized_new(object_in_field, context) }
+ expect(resolved).to eq('Resolved value')
+ end
- subject(:resolved) { service.authorized_resolve.call(type_instance, {}, context) }
+ it 'returns nil if the value was not authorized' do
+ allow(Ability).to receive(:allowed?).and_return false
- it 'checks both field & type permissions' do
- spy_ability_check_for(:read_field, object_in_field, passed: true)
- spy_ability_check_for(:read_type, object_in_field, passed: true)
+ expect(resolved).to be_nil
+ end
+ end
- expect(resolved).to eq(object_in_field)
- end
+ context 'when the field is a built-in scalar type' do
+ let(:type_class) { type_with_field(GraphQL::STRING_TYPE, :read_field) }
+ let(:expected_permissions) { [:read_field] }
- it 'returns nil if viewing was not allowed' do
- spy_ability_check_for(:read_field, object_in_field, passed: false)
- spy_ability_check_for(:read_type, object_in_field, passed: true)
+ it_behaves_like 'checking permissions on the presented object'
+ end
- expect(resolved).to be_nil
+ context 'when the field is a list of scalar types' do
+ let(:type_class) { type_with_field([GraphQL::STRING_TYPE], :read_field) }
+ let(:expected_permissions) { [:read_field] }
+
+ it_behaves_like 'checking permissions on the presented object'
+ end
+
+ context 'when the field is sub-classed scalar type' do
+ let(:type_class) { type_with_field(Types::TimeType, :read_field) }
+ let(:expected_permissions) { [:read_field] }
+
+ it_behaves_like 'checking permissions on the presented object'
+ end
+
+ context 'when the field is a list of sub-classed scalar types' do
+ let(:type_class) { type_with_field([Types::TimeType], :read_field) }
+ let(:expected_permissions) { [:read_field] }
+
+ it_behaves_like 'checking permissions on the presented object'
+ end
end
- context 'when the field is not nullable' do
- let(:type_class) { type_with_field(custom_type, :read_field, object_in_field, null: false) }
+ context 'when the field is a connection' do
+ context 'when it resolves to nil' do
+ let(:type_class) { type_with_field(Types::QueryType.connection_type, :read_field, nil) }
+
+ it 'does not fail when authorizing' do
+ expect(resolved).to be_nil
+ end
+ end
- it 'returns nil when viewing is not allowed' do
- spy_ability_check_for(:read_type, object_in_field, passed: false)
+ context 'when it returns values' do
+ let(:objects) { [1, 2, 3] }
+ let(:field_type) { type([:read_object]).connection_type }
+ let(:type_class) { type_with_field(field_type, [], objects) }
- expect(resolved).to be_nil
+ it 'filters out unauthorized values' do
+ spy_ability_check_for(:read_object, 1, passed: true)
+ spy_ability_check_for(:read_object, 2, passed: false)
+ spy_ability_check_for(:read_object, 3, passed: true)
+
+ expect(resolved.nodes).to eq [1, 3]
+ end
end
end
- context 'when the field is a list' do
- let(:object_1) { double('presented in field 1') }
- let(:object_2) { double('presented in field 2') }
- let(:presented_types) { [double(object: object_1), double(object: object_2)] }
+ context 'when the field is a specific type' do
+ let(:custom_type) { type(:read_type) }
+ let(:object_in_field) { double('presented in field') }
+
+ let(:type_class) { type_with_field(custom_type, :read_field, object_in_field) }
+ let(:type_instance) { type_class.authorized_new(object_in_field, context) }
+
+ it 'checks both field & type permissions' do
+ spy_ability_check_for(:read_field, object_in_field, passed: true)
+ spy_ability_check_for(:read_type, object_in_field, passed: true)
+
+ expect(resolved).to eq(object_in_field)
+ end
+
+ it 'returns nil if viewing was not allowed' do
+ spy_ability_check_for(:read_field, object_in_field, passed: false)
+ spy_ability_check_for(:read_type, object_in_field, passed: true)
- let(:type_class) { type_with_field([custom_type], :read_field, presented_types) }
- let(:type_instance) { type_class.authorized_new(presented_types, context) }
+ expect(resolved).to be_nil
+ end
- it 'checks all permissions' do
- allow(Ability).to receive(:allowed?) { true }
+ context 'when the field is not nullable' do
+ let(:type_class) { type_with_field(custom_type, :read_field, object_in_field, null: false) }
- spy_ability_check_for(:read_field, object_1, passed: true)
- spy_ability_check_for(:read_type, object_1, passed: true)
- spy_ability_check_for(:read_field, object_2, passed: true)
- spy_ability_check_for(:read_type, object_2, passed: true)
+ it 'returns nil when viewing is not allowed' do
+ spy_ability_check_for(:read_type, object_in_field, passed: false)
- expect(resolved).to eq(presented_types)
+ expect(resolved).to be_nil
+ end
end
- it 'filters out objects that the user cannot see' do
- allow(Ability).to receive(:allowed?) { true }
+ context 'when the field is a list' do
+ let(:object_1) { double('presented in field 1') }
+ let(:object_2) { double('presented in field 2') }
+ let(:presented_types) { [double(object: object_1), double(object: object_2)] }
+
+ let(:type_class) { type_with_field([custom_type], :read_field, presented_types) }
+ let(:type_instance) { type_class.authorized_new(presented_types, context) }
+
+ it 'checks all permissions' do
+ allow(Ability).to receive(:allowed?) { true }
- spy_ability_check_for(:read_type, object_1, passed: false)
+ spy_ability_check_for(:read_field, object_1, passed: true)
+ spy_ability_check_for(:read_type, object_1, passed: true)
+ spy_ability_check_for(:read_field, object_2, passed: true)
+ spy_ability_check_for(:read_type, object_2, passed: true)
- expect(resolved.map(&:object)).to contain_exactly(object_2)
+ expect(resolved).to eq(presented_types)
+ end
+
+ it 'filters out objects that the user cannot see' do
+ allow(Ability).to receive(:allowed?) { true }
+
+ spy_ability_check_for(:read_type, object_1, passed: false)
+
+ expect(resolved).to contain_exactly(have_attributes(object: object_2))
+ end
end
end
end
+
+ it_behaves_like 'authorizing fields'
end
private
diff --git a/spec/lib/gitlab/graphql/lazy_spec.rb b/spec/lib/gitlab/graphql/lazy_spec.rb
new file mode 100644
index 00000000000..795978ab0a4
--- /dev/null
+++ b/spec/lib/gitlab/graphql/lazy_spec.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Graphql::Lazy do
+ def load(key)
+ BatchLoader.for(key).batch do |keys, loader|
+ keys.each { |x| loader.call(x, x * x) }
+ end
+ end
+
+ let(:value) { double(x: 1) }
+
+ describe '#force' do
+ subject { described_class.new { value.x } }
+
+ it 'can extract the value' do
+ expect(subject.force).to be 1
+ end
+
+ it 'can derive new lazy values' do
+ expect(subject.then { |x| x + 2 }.force).to be 3
+ end
+
+ it 'only evaluates once' do
+ expect(value).to receive(:x).once
+
+ expect(subject.force).to eq(subject.force)
+ end
+
+ it 'deals with nested laziness' do
+ expect(described_class.new { load(10) }.force).to eq(100)
+ expect(described_class.new { described_class.new { 5 } }.force).to eq 5
+ end
+ end
+
+ describe '.with_value' do
+ let(:inner) { described_class.new { value.x } }
+
+ subject { described_class.with_value(inner) { |x| x.to_s } }
+
+ it 'defers the application of a block to a value' do
+ expect(value).not_to receive(:x)
+
+ expect(subject).to be_an_instance_of(described_class)
+ end
+
+ it 'evaluates to the application of the block to the value' do
+ expect(value).to receive(:x).once
+
+ expect(subject.force).to eq(inner.force.to_s)
+ end
+ end
+
+ describe '.force' do
+ context 'when given a plain value' do
+ subject { described_class.force(1) }
+
+ it 'unwraps the value' do
+ expect(subject).to be 1
+ end
+ end
+
+ context 'when given a wrapped lazy value' do
+ subject { described_class.force(described_class.new { 2 }) }
+
+ it 'unwraps the value' do
+ expect(subject).to be 2
+ end
+ end
+
+ context 'when the value is from a batchloader' do
+ subject { described_class.force(load(3)) }
+
+ it 'syncs the value' do
+ expect(subject).to be 9
+ end
+ end
+
+ context 'when the value is a GraphQL lazy' do
+ subject { described_class.force(GitlabSchema.after_lazy(load(3)) { |x| x + 1 } ) }
+
+ it 'forces the evaluation' do
+ expect(subject).to be 10
+ end
+ end
+
+ context 'when the value is a promise' do
+ subject { described_class.force(::Concurrent::Promise.new { 4 }) }
+
+ it 'executes the promise and waits for the value' do
+ expect(subject).to be 4
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb b/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb
index cf1f00bc176..7ae33346388 100644
--- a/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb
+++ b/spec/lib/gitlab/graphql/loaders/batch_model_loader_spec.rb
@@ -4,8 +4,9 @@ require 'spec_helper'
RSpec.describe Gitlab::Graphql::Loaders::BatchModelLoader do
describe '#find' do
- let(:issue) { create(:issue) }
- let(:user) { create(:user) }
+ let_it_be(:issue) { create(:issue) }
+ let_it_be(:other_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
it 'finds a model by id' do
issue_result = described_class.new(Issue, issue.id).find
@@ -16,15 +17,25 @@ RSpec.describe Gitlab::Graphql::Loaders::BatchModelLoader do
end
it 'only queries once per model' do
- other_user = create(:user)
- user
- issue
-
expect do
[described_class.new(User, other_user.id).find,
described_class.new(User, user.id).find,
described_class.new(Issue, issue.id).find].map(&:sync)
end.not_to exceed_query_limit(2)
end
+
+ it 'does not force values unnecessarily' do
+ expect do
+ a = described_class.new(User, user.id).find
+ b = described_class.new(Issue, issue.id).find
+
+ b.sync
+
+ c = described_class.new(User, other_user.id).find
+
+ a.sync
+ c.sync
+ end.not_to exceed_query_limit(2)
+ end
end
end
diff --git a/spec/lib/gitlab/hook_data/release_builder_spec.rb b/spec/lib/gitlab/hook_data/release_builder_spec.rb
new file mode 100644
index 00000000000..b630780b162
--- /dev/null
+++ b/spec/lib/gitlab/hook_data/release_builder_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::HookData::ReleaseBuilder do
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let(:release) { create(:release, project: project) }
+ let(:builder) { described_class.new(release) }
+
+ describe '#build' do
+ let(:data) { builder.build('create') }
+
+ it 'includes safe attribute' do
+ %w[
+ id
+ created_at
+ description
+ name
+ released_at
+ tag
+ ].each do |key|
+ expect(data).to include(key)
+ end
+ end
+
+ it 'includes additional attrs' do
+ expect(data[:object_kind]).to eq('release')
+ expect(data[:project]).to eq(builder.release.project.hook_attrs.with_indifferent_access)
+ expect(data[:action]).to eq('create')
+ expect(data).to include(:assets)
+ expect(data).to include(:commit)
+ end
+
+ context 'when the Release has an image in the description' do
+ let(:release_with_description) do
+ create(:release, project: project, description: 'test![Release_Image](/uploads/abc/Release_Image.png)')
+ end
+
+ let(:builder) { described_class.new(release_with_description) }
+
+ it 'sets the image to use an absolute URL' do
+ expected_path = "#{release_with_description.project.path_with_namespace}/uploads/abc/Release_Image.png"
+
+ expect(data[:description])
+ .to eq("test![Release_Image](#{Settings.gitlab.url}/#{expected_path})")
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/i18n/po_linter_spec.rb b/spec/lib/gitlab/i18n/po_linter_spec.rb
index 9165ccfb1ef..e04c0b49480 100644
--- a/spec/lib/gitlab/i18n/po_linter_spec.rb
+++ b/spec/lib/gitlab/i18n/po_linter_spec.rb
@@ -461,9 +461,10 @@ RSpec.describe Gitlab::I18n::PoLinter do
fake_metadata = double
allow(fake_metadata).to receive(:forms_to_test).and_return(4)
allow(linter).to receive(:metadata_entry).and_return(fake_metadata)
- allow(linter).to receive(:locale).and_return('pl_PL')
- numbers = linter.numbers_covering_all_plurals
+ numbers = Gitlab::I18n.with_locale('pl_PL') do
+ linter.numbers_covering_all_plurals
+ end
expect(numbers).to contain_exactly(0, 1, 2)
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index 5ee7fb2adbf..38fe2781331 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -179,6 +179,7 @@ merge_requests:
- user_mentions
- system_note_metadata
- note_authors
+- cleanup_schedule
external_pull_requests:
- project
merge_request_diff:
@@ -195,6 +196,8 @@ merge_request_diff_files:
merge_request_context_commits:
- merge_request
- diff_files
+cleanup_schedule:
+- merge_request
ci_pipelines:
- project
- user
@@ -240,6 +243,7 @@ ci_pipelines:
- vulnerability_findings
- pipeline_config
- security_scans
+- security_findings
- daily_build_group_report_results
- latest_builds
- daily_report_results
@@ -317,6 +321,7 @@ push_access_levels:
- protected_branch
- user
- group
+- deploy_key
create_access_levels:
- user
- protected_tag
@@ -652,6 +657,7 @@ milestone_releases:
evidences:
- release
design: &design
+- authors
- issue
- actions
- versions
diff --git a/spec/lib/gitlab/import_export/attributes_finder_spec.rb b/spec/lib/gitlab/import_export/attributes_finder_spec.rb
index 7f6ebf577af..428d8d605ee 100644
--- a/spec/lib/gitlab/import_export/attributes_finder_spec.rb
+++ b/spec/lib/gitlab/import_export/attributes_finder_spec.rb
@@ -59,7 +59,7 @@ RSpec.describe Gitlab::ImportExport::AttributesFinder do
end
before do
- allow_any_instance_of(Gitlab::ImportExport).to receive(:config_file).and_return(test_config)
+ allow(Gitlab::ImportExport).to receive(:config_file).and_return(test_config)
end
it 'generates hash from project tree config' do
diff --git a/spec/lib/gitlab/import_export/group/legacy_tree_saver_spec.rb b/spec/lib/gitlab/import_export/group/legacy_tree_saver_spec.rb
index 6b324b952dc..9e1571ae3d8 100644
--- a/spec/lib/gitlab/import_export/group/legacy_tree_saver_spec.rb
+++ b/spec/lib/gitlab/import_export/group/legacy_tree_saver_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::ImportExport::Group::LegacyTreeSaver do
before do
group.add_maintainer(user)
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
end
after do
diff --git a/spec/lib/gitlab/import_export/importer_spec.rb b/spec/lib/gitlab/import_export/importer_spec.rb
index dc44296321c..0db038785d3 100644
--- a/spec/lib/gitlab/import_export/importer_spec.rb
+++ b/spec/lib/gitlab/import_export/importer_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe Gitlab::ImportExport::Importer do
subject(:importer) { described_class.new(project) }
before do
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(test_path)
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(test_path)
allow_any_instance_of(Gitlab::ImportExport::FileImporter).to receive(:remove_import_file)
stub_uploads_object_storage(FileUploader)
@@ -65,10 +65,22 @@ RSpec.describe Gitlab::ImportExport::Importer do
end
end
- it 'restores the ProjectTree' do
- expect(Gitlab::ImportExport::Project::TreeRestorer).to receive(:new).and_call_original
+ context 'with sample_data_template' do
+ it 'initializes the Sample::TreeRestorer' do
+ project.create_or_update_import_data(data: { sample_data: true })
- importer.execute
+ expect(Gitlab::ImportExport::Project::Sample::TreeRestorer).to receive(:new).and_call_original
+
+ importer.execute
+ end
+ end
+
+ context 'without sample_data_template' do
+ it 'initializes the ProjectTree' do
+ expect(Gitlab::ImportExport::Project::TreeRestorer).to receive(:new).and_call_original
+
+ importer.execute
+ end
end
it 'removes the import file' 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 e208a1c383c..b477ac45577 100644
--- a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
+++ b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb
@@ -67,6 +67,14 @@ RSpec.describe Gitlab::ImportExport::JSON::NdjsonReader do
it 'yields nothing to the Enumerator' do
expect(subject.to_a).to eq([])
end
+
+ context 'with mark_as_consumed: false' do
+ subject { ndjson_reader.consume_relation(importable_path, key, mark_as_consumed: false) }
+
+ it 'yields every relation value to the Enumerator' do
+ expect(subject.count).to eq(1)
+ end
+ end
end
context 'key has not been consumed' do
@@ -102,14 +110,4 @@ RSpec.describe Gitlab::ImportExport::JSON::NdjsonReader do
end
end
end
-
- describe '#clear_consumed_relations' do
- let(:dir_path) { fixture }
-
- subject { ndjson_reader.clear_consumed_relations }
-
- it 'returns empty set' do
- expect(subject).to be_empty
- end
- end
end
diff --git a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
index 949cfb5a34d..762687beedb 100644
--- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
+++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe Gitlab::ImportExport::JSON::StreamingSerializer do
group: group,
approvals_before_merge: 1)
end
+
let_it_be(:issue) do
create(:issue,
assignees: [user],
diff --git a/spec/lib/gitlab/import_export/lfs_restorer_spec.rb b/spec/lib/gitlab/import_export/lfs_restorer_spec.rb
index a9f7fb72612..c8887b0ded1 100644
--- a/spec/lib/gitlab/import_export/lfs_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/lfs_restorer_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::ImportExport::LfsRestorer do
subject(:restorer) { described_class.new(project: project, shared: shared) }
before do
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
FileUtils.mkdir_p(shared.export_path)
end
diff --git a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
index 50bc6a30044..56ba730e893 100644
--- a/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
+++ b/spec/lib/gitlab/import_export/project/relation_factory_spec.rb
@@ -61,6 +61,7 @@ RSpec.describe Gitlab::ImportExport::Project::RelationFactory do
'enable_ssl_verification' => true,
'job_events' => false,
'wiki_page_events' => true,
+ 'releases_events' => false,
'token' => token
}
end
diff --git a/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb b/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb
index 82f59245519..645242c6f05 100644
--- a/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb
+++ b/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::ImportExport::Project::Sample::DateCalculator do
end
context 'when dates are not empty' do
- let(:dates) { [[nil, '2020-01-01 00:00:00 +0000'], [nil, '2021-01-01 00:00:00 +0000'], [nil, '2022-01-01 23:59:59 +0000']] }
+ let(:dates) { [nil, '2020-01-01 00:00:00 +0000', '2021-01-01 00:00:00 +0000', nil, '2022-01-01 23:59:59 +0000'] }
it { is_expected.to eq(Time.zone.parse('2021-01-01 00:00:00 +0000')) }
end
diff --git a/spec/lib/gitlab/import_export/project/sample/relation_factory_spec.rb b/spec/lib/gitlab/import_export/project/sample/relation_factory_spec.rb
new file mode 100644
index 00000000000..86d5f2402f8
--- /dev/null
+++ b/spec/lib/gitlab/import_export/project/sample/relation_factory_spec.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::ImportExport::Project::Sample::RelationFactory do
+ let(:group) { create(:group) }
+ let(:project) { create(:project, :repository, group: group) }
+ let(:members_mapper) { double('members_mapper').as_null_object }
+ let(:admin) { create(:admin) }
+ let(:importer_user) { admin }
+ let(:excluded_keys) { [] }
+ let(:date_calculator) { instance_double(Gitlab::ImportExport::Project::Sample::DateCalculator) }
+ let(:original_project_id) { 8 }
+ let(:start_date) { Time.current - 30.days }
+ let(:due_date) { Time.current - 20.days }
+ let(:created_object) do
+ described_class.create( # rubocop:disable Rails/SaveBang
+ relation_sym: relation_sym,
+ relation_hash: relation_hash,
+ object_builder: Gitlab::ImportExport::Project::ObjectBuilder,
+ members_mapper: members_mapper,
+ user: importer_user,
+ importable: project,
+ excluded_keys: excluded_keys,
+ date_calculator: date_calculator
+ )
+ end
+
+ context 'issue object' do
+ let(:relation_sym) { :issues }
+ let(:id) { 999 }
+
+ let(:relation_hash) do
+ {
+ 'id' => id,
+ 'title' => 'Necessitatibus magnam qui at velit consequatur perspiciatis.',
+ 'project_id' => original_project_id,
+ 'created_at' => '2016-08-12T09:41:03.462Z',
+ 'updated_at' => '2016-08-12T09:41:03.462Z',
+ 'description' => 'Molestiae corporis magnam et fugit aliquid nulla quia.',
+ 'state' => 'closed',
+ 'position' => 0,
+ 'confidential' => false,
+ 'due_date' => due_date
+ }
+ end
+
+ before do
+ allow(date_calculator).to receive(:closest_date_to_average) { Time.current - 10.days }
+ allow(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ end
+
+ it 'correctly updated due date', :aggregate_failures do
+ expect(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ .with(relation_hash['due_date']).and_return(due_date - 10.days)
+
+ expect(created_object.due_date).to eq((due_date - 10.days).to_date)
+ end
+ end
+
+ context 'milestone object' do
+ let(:relation_sym) { :milestones }
+ let(:id) { 1001 }
+
+ let(:relation_hash) do
+ {
+ 'id' => id,
+ 'title' => 'v3.0',
+ 'project_id' => original_project_id,
+ 'created_at' => '2016-08-12T09:41:03.462Z',
+ 'updated_at' => '2016-08-12T09:41:03.462Z',
+ 'description' => 'Rerum at autem exercitationem ea voluptates harum quam placeat.',
+ 'state' => 'closed',
+ 'start_date' => start_date,
+ 'due_date' => due_date
+ }
+ end
+
+ before do
+ allow(date_calculator).to receive(:closest_date_to_average).twice { Time.current - 10.days }
+ allow(date_calculator).to receive(:calculate_by_closest_date_to_average).twice
+ end
+
+ it 'correctly updated due date', :aggregate_failures do
+ expect(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ .with(relation_hash['due_date']).and_return(due_date - 10.days)
+
+ expect(created_object.due_date).to eq((due_date - 10.days).to_date)
+ end
+
+ it 'correctly updated start date', :aggregate_failures do
+ expect(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ .with(relation_hash['start_date']).and_return(start_date - 20.days)
+
+ expect(created_object.start_date).to eq((start_date - 20.days).to_date)
+ end
+ end
+
+ context 'milestone object' do
+ let(:relation_sym) { :milestones }
+ let(:id) { 1001 }
+
+ let(:relation_hash) do
+ {
+ 'id' => id,
+ 'title' => 'v3.0',
+ 'project_id' => original_project_id,
+ 'created_at' => '2016-08-12T09:41:03.462Z',
+ 'updated_at' => '2016-08-12T09:41:03.462Z',
+ 'description' => 'Rerum at autem exercitationem ea voluptates harum quam placeat.',
+ 'state' => 'closed',
+ 'start_date' => start_date,
+ 'due_date' => due_date
+ }
+ end
+
+ before do
+ allow(date_calculator).to receive(:closest_date_to_average).twice { Time.current - 10.days }
+ allow(date_calculator).to receive(:calculate_by_closest_date_to_average).twice
+ end
+
+ it 'correctly updated due date', :aggregate_failures do
+ expect(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ .with(relation_hash['due_date']).and_return(due_date - 10.days)
+
+ expect(created_object.due_date).to eq((due_date - 10.days).to_date)
+ end
+
+ it 'correctly updated start date', :aggregate_failures do
+ expect(date_calculator).to receive(:calculate_by_closest_date_to_average)
+ .with(relation_hash['start_date']).and_return(start_date - 20.days)
+
+ expect(created_object.start_date).to eq((start_date - 20.days).to_date)
+ end
+ end
+
+ context 'hook object' do
+ let(:relation_sym) { :hooks }
+ let(:id) { 999 }
+ let(:service_id) { 99 }
+ let(:token) { 'secret' }
+
+ let(:relation_hash) do
+ {
+ 'id' => id,
+ 'url' => 'https://example.json',
+ 'project_id' => original_project_id,
+ 'created_at' => '2016-08-12T09:41:03.462Z',
+ 'updated_at' => '2016-08-12T09:41:03.462Z',
+ 'service_id' => service_id,
+ 'push_events' => true,
+ 'issues_events' => false,
+ 'confidential_issues_events' => false,
+ 'merge_requests_events' => true,
+ 'tag_push_events' => false,
+ 'note_events' => true,
+ 'enable_ssl_verification' => true,
+ 'job_events' => false,
+ 'wiki_page_events' => true,
+ 'token' => token
+ }
+ end
+
+ it 'does not calculate the closest date to average' do
+ expect(date_calculator).not_to receive(:calculate_by_closest_date_to_average)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb
index f173345a4c6..f87f79d4462 100644
--- a/spec/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/sample/relation_tree_restorer_spec.rb
@@ -9,7 +9,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::ImportExport::Project::Sample::SampleDataRelationTreeRestorer do
+RSpec.describe Gitlab::ImportExport::Project::Sample::RelationTreeRestorer do
include_context 'relation tree restorer shared context'
let(:sample_data_relation_tree_restorer) do
@@ -74,13 +74,26 @@ RSpec.describe Gitlab::ImportExport::Project::Sample::SampleDataRelationTreeRest
let(:importable_name) { 'project' }
let(:importable_path) { 'project' }
let(:object_builder) { Gitlab::ImportExport::Project::ObjectBuilder }
- let(:relation_factory) { Gitlab::ImportExport::Project::RelationFactory }
+ let(:relation_factory) { Gitlab::ImportExport::Project::Sample::RelationFactory }
let(:reader) { Gitlab::ImportExport::Reader.new(shared: shared) }
+ let(:path) { 'spec/fixtures/lib/gitlab/import_export/sample_data/tree' }
+ let(:relation_reader) { Gitlab::ImportExport::JSON::NdjsonReader.new(path) }
- context 'using ndjson reader' do
- let(:path) { 'spec/fixtures/lib/gitlab/import_export/sample_data/tree' }
- let(:relation_reader) { Gitlab::ImportExport::JSON::NdjsonReader.new(path) }
+ it 'initializes relation_factory with date_calculator as parameter' do
+ expect(Gitlab::ImportExport::Project::Sample::RelationFactory).to receive(:create).with(hash_including(:date_calculator)).at_least(:once).times
+
+ subject
+ end
+
+ context 'when relation tree restorer is initialized' do
+ it 'initializes date calculator with due dates' do
+ expect(Gitlab::ImportExport::Project::Sample::DateCalculator).to receive(:new).with(Array)
+ sample_data_relation_tree_restorer
+ end
+ end
+
+ context 'using ndjson reader' do
it_behaves_like 'import project successfully'
end
end
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 c05968c9a85..fd3b71deb37 100644
--- a/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
+++ b/spec/lib/gitlab/import_export/project/tree_restorer_spec.rb
@@ -681,13 +681,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do
end
it 'overrides project feature access levels' do
- access_level_keys = project.project_feature.attributes.keys.select { |a| a =~ /_access_level/ }
-
- # `pages_access_level` is not included, since it is not available in the public API
- # and has a dependency on project's visibility level
- # see ProjectFeature model
- access_level_keys.delete('pages_access_level')
-
+ access_level_keys = ProjectFeature.available_features.map { |feature| ProjectFeature.access_level_attribute(feature) }
disabled_access_levels = Hash[access_level_keys.collect { |item| [item, 'disabled'] }]
project.create_import_data(data: { override_params: disabled_access_levels })
@@ -979,6 +973,7 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer 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) }
@@ -1040,41 +1035,6 @@ RSpec.describe Gitlab::ImportExport::Project::TreeRestorer do
it_behaves_like 'project tree restorer work properly', :legacy_reader, true
it_behaves_like 'project tree restorer work properly', :ndjson_reader, true
-
- context 'Sample Data JSON' do
- let(:user) { create(:user) }
- let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
- let(:project_tree_restorer) { described_class.new(user: user, shared: shared, project: project) }
-
- before do
- setup_import_export_config('sample_data')
- setup_reader(:ndjson_reader)
- end
-
- context 'with sample_data_template' do
- before do
- allow(project).to receive_message_chain(:import_data, :data, :dig).with('sample_data') { true }
- end
-
- it 'initialize SampleDataRelationTreeRestorer' do
- expect_next_instance_of(Gitlab::ImportExport::Project::Sample::SampleDataRelationTreeRestorer) do |restorer|
- expect(restorer).to receive(:restore).and_return(true)
- end
-
- expect(project_tree_restorer.restore).to eq(true)
- end
- end
-
- context 'without sample_data_template' do
- it 'initialize RelationTreeRestorer' do
- expect_next_instance_of(Gitlab::ImportExport::RelationTreeRestorer) do |restorer|
- expect(restorer).to receive(:restore).and_return(true)
- end
-
- expect(project_tree_restorer.restore).to eq(true)
- end
- end
- end
end
context 'disable ndjson import' do
diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml
index e3d1f2c9368..b33462b4096 100644
--- a/spec/lib/gitlab/import_export/safe_model_attributes.yml
+++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml
@@ -489,6 +489,7 @@ ProjectHook:
- confidential_issues_events
- confidential_note_events
- repository_update_events
+- releases_events
ProtectedBranch:
- id
- project_id
@@ -575,6 +576,7 @@ ProjectFeature:
- repository_access_level
- pages_access_level
- metrics_dashboard_access_level
+- requirements_access_level
- created_at
- updated_at
ProtectedBranch::MergeAccessLevel:
@@ -683,6 +685,7 @@ ProjectCiCdSetting:
ProjectSetting:
- allow_merge_on_skipped_pipeline
- has_confluence
+- has_vulnerabilities
ProtectedEnvironment:
- id
- project_id
@@ -771,6 +774,7 @@ ExternalPullRequest:
- target_sha
DesignManagement::Design:
- id
+- iid
- project_id
- filename
- relative_position
@@ -858,3 +862,25 @@ ProjectSecuritySetting:
IssuableSla:
- issue_id
- due_at
+PushRule:
+ - force_push_regex
+ - delete_branch_regex
+ - commit_message_regex
+ - author_email_regex
+ - file_name_regex
+ - branch_name_regex
+ - commit_message_negative_regex
+ - max_file_size
+ - deny_delete_tag
+ - member_check
+ - is_sample
+ - prevent_secrets
+ - reject_unsigned_commits
+ - commit_committer_check
+ - regexp_uses_re2
+MergeRequest::CleanupSchedule:
+- id
+- scheduled_at
+- completed_at
+- created_at
+- updated_at
diff --git a/spec/lib/gitlab/import_export/uploads_manager_spec.rb b/spec/lib/gitlab/import_export/uploads_manager_spec.rb
index 33ad0e12c37..8282ad9a070 100644
--- a/spec/lib/gitlab/import_export/uploads_manager_spec.rb
+++ b/spec/lib/gitlab/import_export/uploads_manager_spec.rb
@@ -23,13 +23,13 @@ RSpec.describe Gitlab::ImportExport::UploadsManager do
end
describe '#save' do
+ before do
+ project.uploads << upload
+ end
+
context 'when the project has uploads locally stored' do
let(:upload) { create(:upload, :issuable_upload, :with_file, model: project) }
- before do
- project.uploads << upload
- end
-
it 'does not cause errors' do
manager.save
@@ -74,6 +74,22 @@ RSpec.describe Gitlab::ImportExport::UploadsManager do
end
end
end
+
+ context 'when upload is in object storage' do
+ before do
+ stub_uploads_object_storage(FileUploader)
+ allow(manager).to receive(:download_or_copy_upload).and_raise(Errno::ENAMETOOLONG)
+ end
+
+ it 'ignores problematic upload and logs exception' do
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(Errno::ENAMETOOLONG), project_id: project.id)
+
+ manager.save
+
+ expect(shared.errors).to be_empty
+ expect(File).not_to exist(exported_file_path)
+ end
+ end
end
describe '#restore' do
diff --git a/spec/lib/gitlab/instrumentation_helper_spec.rb b/spec/lib/gitlab/instrumentation_helper_spec.rb
index 35bbdcdccd6..88f2def34d9 100644
--- a/spec/lib/gitlab/instrumentation_helper_spec.rb
+++ b/spec/lib/gitlab/instrumentation_helper_spec.rb
@@ -97,6 +97,16 @@ RSpec.describe Gitlab::InstrumentationHelper do
expect(payload[:gitaly_duration]).to be_nil
end
end
+
+ context 'when the request matched a Rack::Attack safelist' do
+ it 'logs the safelist name' do
+ Gitlab::Instrumentation::Throttle.safelist = 'foobar'
+
+ subject
+
+ expect(payload[:throttle_safelist]).to eq('foobar')
+ end
+ end
end
describe '.queue_duration_for_job' do
diff --git a/spec/lib/gitlab/jira_import_spec.rb b/spec/lib/gitlab/jira_import_spec.rb
index c8cecb576da..2b602c80640 100644
--- a/spec/lib/gitlab/jira_import_spec.rb
+++ b/spec/lib/gitlab/jira_import_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::JiraImport do
let_it_be(:project, reload: true) { create(:project) }
let(:additional_params) { {} }
- subject { described_class.validate_project_settings!(project, additional_params) }
+ subject { described_class.validate_project_settings!(project, **additional_params) }
shared_examples 'raise Jira import error' do |message|
it 'returns error' do
diff --git a/spec/lib/gitlab/json_spec.rb b/spec/lib/gitlab/json_spec.rb
index 0402296a3a8..59ec94f2855 100644
--- a/spec/lib/gitlab/json_spec.rb
+++ b/spec/lib/gitlab/json_spec.rb
@@ -7,342 +7,306 @@ RSpec.describe Gitlab::Json do
stub_feature_flags(json_wrapper_legacy_mode: true)
end
- shared_examples "json" do
- describe ".parse" do
- 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 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
+ describe ".parse" do
+ context "legacy_mode is disabled by default" do
+ it "parses an object" do
+ expect(subject.parse('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
end
- 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
+ it "parses an array" do
+ expect(subject.parse('[{ "foo": "bar" }]')).to eq([{ "foo" => "bar" }])
end
- 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 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: true)).to be(true)
- 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: true)).to be(false)
- end
+ it "parses a false bool" do
+ expect(subject.parse("false", legacy_mode: false)).to be(false)
end
end
- describe ".parse!" do
- context "legacy_mode is disabled by default" do
- it "parses an object" do
- expect(subject.parse!('{ "foo": "bar" }')).to eq({ "foo" => "bar" })
- end
+ 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" }]')).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: false)).to eq("foo")
- end
+ it "raises an error on a string" do
+ expect { subject.parse('"foo"', legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
- it "parses a true bool" do
- expect(subject.parse!("true", legacy_mode: false)).to be(true)
- end
+ it "raises an error on a true bool" do
+ expect { subject.parse("true", legacy_mode: true) }.to raise_error(JSON::ParserError)
+ end
- it "parses a false bool" do
- expect(subject.parse!("false", legacy_mode: false)).to be(false)
- end
+ it "raises an error on a false bool" do
+ expect { subject.parse("false", legacy_mode: true) }.to raise_error(JSON::ParserError)
end
+ end
- context "legacy_mode is enabled" do
- it "parses an object" do
- expect(subject.parse!('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
- end
+ context "feature flag is disabled" do
+ before do
+ stub_feature_flags(json_wrapper_legacy_mode: false)
+ end
- it "parses an array" do
- expect(subject.parse!('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
- end
+ it "parses an object" 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 "parses an array" do
+ expect(subject.parse('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
+ end
- it "raises an error on a true bool" do
- expect { subject.parse!("true", legacy_mode: true) }.to raise_error(JSON::ParserError)
- end
+ it "parses a string" do
+ expect(subject.parse('"foo"', legacy_mode: true)).to eq("foo")
+ end
- it "raises an error on a false bool" do
- expect { subject.parse!("false", legacy_mode: true) }.to raise_error(JSON::ParserError)
- end
+ it "parses a true bool" do
+ expect(subject.parse("true", legacy_mode: true)).to be(true)
end
- context "feature flag is disabled" do
- before do
- stub_feature_flags(json_wrapper_legacy_mode: false)
- end
+ it "parses a false bool" do
+ expect(subject.parse("false", legacy_mode: true)).to be(false)
+ end
+ end
+ end
- it "parses an object" do
- expect(subject.parse!('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
- end
+ describe ".parse!" do
+ 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" }]', legacy_mode: true)).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: true)).to eq("foo")
- end
+ 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: true)).to be(true)
- 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: true)).to be(false)
- end
+ it "parses a false bool" do
+ expect(subject.parse!("false", legacy_mode: false)).to be(false)
end
end
- describe ".dump" do
- it "dumps an object" do
- expect(subject.dump({ "foo" => "bar" })).to eq('{"foo":"bar"}')
+ 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 "dumps an array" do
- expect(subject.dump([{ "foo" => "bar" }])).to eq('[{"foo":"bar"}]')
+ it "parses an array" do
+ expect(subject.parse!('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
end
- it "dumps a string" do
- expect(subject.dump("foo")).to eq('"foo"')
+ it "raises an error on a string" do
+ expect { subject.parse!('"foo"', legacy_mode: true) }.to raise_error(JSON::ParserError)
end
- it "dumps a true bool" do
- expect(subject.dump(true)).to eq("true")
+ it "raises an error on a true bool" do
+ expect { subject.parse!("true", legacy_mode: true) }.to raise_error(JSON::ParserError)
end
- it "dumps a false bool" do
- expect(subject.dump(false)).to eq("false")
+ it "raises an error on a false bool" do
+ expect { subject.parse!("false", legacy_mode: true) }.to raise_error(JSON::ParserError)
end
end
- describe ".generate" do
- let(:obj) do
- { test: true, "foo.bar" => "baz", is_json: 1, some: [1, 2, 3] }
+ context "feature flag is disabled" do
+ before do
+ stub_feature_flags(json_wrapper_legacy_mode: false)
end
- it "generates JSON" do
- expected_string = <<~STR.chomp
- {"test":true,"foo.bar":"baz","is_json":1,"some":[1,2,3]}
- STR
+ it "parses an object" do
+ expect(subject.parse!('{ "foo": "bar" }', legacy_mode: true)).to eq({ "foo" => "bar" })
+ end
- expect(subject.generate(obj)).to eq(expected_string)
+ it "parses an array" do
+ expect(subject.parse!('[{ "foo": "bar" }]', legacy_mode: true)).to eq([{ "foo" => "bar" }])
end
- it "allows you to customise the output" do
- opts = {
- indent: " ",
- space: " ",
- space_before: " ",
- object_nl: "\n",
- array_nl: "\n"
- }
+ it "parses a string" do
+ expect(subject.parse!('"foo"', legacy_mode: true)).to eq("foo")
+ end
- json = subject.generate(obj, opts)
-
- expected_string = <<~STR.chomp
- {
- "test" : true,
- "foo.bar" : "baz",
- "is_json" : 1,
- "some" : [
- 1,
- 2,
- 3
- ]
- }
- STR
+ it "parses a true bool" do
+ expect(subject.parse!("true", legacy_mode: true)).to be(true)
+ end
- expect(json).to eq(expected_string)
+ it "parses a false bool" do
+ expect(subject.parse!("false", legacy_mode: true)).to be(false)
end
end
+ end
- describe ".pretty_generate" do
- let(:obj) do
- {
- test: true,
- "foo.bar" => "baz",
- is_json: 1,
- some: [1, 2, 3],
- more: { test: true },
- multi_line_empty_array: [],
- multi_line_empty_obj: {}
- }
- end
+ describe ".dump" do
+ it "dumps an object" do
+ expect(subject.dump({ "foo" => "bar" })).to eq('{"foo":"bar"}')
+ end
- it "generates pretty JSON" do
- expected_string = <<~STR.chomp
- {
- "test": true,
- "foo.bar": "baz",
- "is_json": 1,
- "some": [
- 1,
- 2,
- 3
- ],
- "more": {
- "test": true
- },
- "multi_line_empty_array": [
-
- ],
- "multi_line_empty_obj": {
- }
- }
- STR
+ it "dumps an array" do
+ expect(subject.dump([{ "foo" => "bar" }])).to eq('[{"foo":"bar"}]')
+ end
- expect(subject.pretty_generate(obj)).to eq(expected_string)
- end
+ it "dumps a string" do
+ expect(subject.dump("foo")).to eq('"foo"')
+ end
- it "allows you to customise the output" do
- opts = {
- space_before: " "
- }
+ it "dumps a true bool" do
+ expect(subject.dump(true)).to eq("true")
+ end
- json = subject.pretty_generate(obj, opts)
-
- expected_string = <<~STR.chomp
- {
- "test" : true,
- "foo.bar" : "baz",
- "is_json" : 1,
- "some" : [
- 1,
- 2,
- 3
- ],
- "more" : {
- "test" : true
- },
- "multi_line_empty_array" : [
-
- ],
- "multi_line_empty_obj" : {
- }
- }
- STR
+ it "dumps a false bool" do
+ expect(subject.dump(false)).to eq("false")
+ end
+ end
- expect(json).to eq(expected_string)
- end
+ describe ".generate" do
+ let(:obj) do
+ { test: true, "foo.bar" => "baz", is_json: 1, some: [1, 2, 3] }
end
- context "the feature table is missing" do
- before do
- allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
- end
+ it "generates JSON" do
+ expected_string = <<~STR.chomp
+ {"test":true,"foo.bar":"baz","is_json":1,"some":[1,2,3]}
+ STR
+
+ expect(subject.generate(obj)).to eq(expected_string)
+ end
- it "skips legacy mode handling" do
- expect(Feature).not_to receive(:enabled?).with(:json_wrapper_legacy_mode, default_enabled: true)
+ it "allows you to customise the output" do
+ opts = {
+ indent: " ",
+ space: " ",
+ space_before: " ",
+ object_nl: "\n",
+ array_nl: "\n"
+ }
- subject.send(:handle_legacy_mode!, {})
- end
+ json = subject.generate(obj, opts)
- it "skips oj feature detection" do
- expect(Feature).not_to receive(:enabled?).with(:oj_json, default_enabled: true)
+ expected_string = <<~STR.chomp
+ {
+ "test" : true,
+ "foo.bar" : "baz",
+ "is_json" : 1,
+ "some" : [
+ 1,
+ 2,
+ 3
+ ]
+ }
+ STR
- subject.send(:enable_oj?)
- end
+ expect(json).to eq(expected_string)
end
+ end
- context "the database is missing" do
- before do
- allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(PG::ConnectionBad)
- end
+ describe ".pretty_generate" do
+ let(:obj) do
+ {
+ test: true,
+ "foo.bar" => "baz",
+ is_json: 1,
+ some: [1, 2, 3],
+ more: { test: true },
+ multi_line_empty_array: [],
+ multi_line_empty_obj: {}
+ }
+ end
- it "still parses json" do
- expect(subject.parse("{}")).to eq({})
- end
+ it "generates pretty JSON" do
+ expected_string = <<~STR.chomp
+ {
+ "test": true,
+ "foo.bar": "baz",
+ "is_json": 1,
+ "some": [
+ 1,
+ 2,
+ 3
+ ],
+ "more": {
+ "test": true
+ },
+ "multi_line_empty_array": [
+
+ ],
+ "multi_line_empty_obj": {
+ }
+ }
+ STR
- it "still generates json" do
- expect(subject.dump({})).to eq("{}")
- end
+ expect(subject.pretty_generate(obj)).to eq(expected_string)
+ end
+
+ it "allows you to customise the output" do
+ opts = {
+ space_before: " "
+ }
+
+ json = subject.pretty_generate(obj, opts)
+
+ expected_string = <<~STR.chomp
+ {
+ "test" : true,
+ "foo.bar" : "baz",
+ "is_json" : 1,
+ "some" : [
+ 1,
+ 2,
+ 3
+ ],
+ "more" : {
+ "test" : true
+ },
+ "multi_line_empty_array" : [
+
+ ],
+ "multi_line_empty_obj" : {
+ }
+ }
+ STR
+
+ expect(json).to eq(expected_string)
end
end
- context "oj gem" do
+ context "the feature table is missing" do
before do
- stub_feature_flags(oj_json: true)
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
end
- it_behaves_like "json"
+ it "skips legacy mode handling" do
+ expect(Feature).not_to receive(:enabled?).with(:json_wrapper_legacy_mode, default_enabled: true)
- describe "#enable_oj?" do
- it "returns true" do
- expect(subject.enable_oj?).to be(true)
- end
+ subject.send(:handle_legacy_mode!, {})
end
end
- context "json gem" do
+ context "the database is missing" do
before do
- stub_feature_flags(oj_json: false)
+ allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise(PG::ConnectionBad)
end
- it_behaves_like "json"
+ it "still parses json" do
+ expect(subject.parse("{}")).to eq({})
+ end
- describe "#enable_oj?" do
- it "returns false" do
- expect(subject.enable_oj?).to be(false)
- end
+ it "still generates json" do
+ expect(subject.dump({})).to eq("{}")
end
end
@@ -353,47 +317,25 @@ RSpec.describe Gitlab::Json do
let(:env) { {} }
let(:result) { "{\"test\":true}" }
- context "oj is enabled" do
+ context "grape_gitlab_json flag is enabled" do
before do
- stub_feature_flags(oj_json: true)
+ stub_feature_flags(grape_gitlab_json: true)
end
- context "grape_gitlab_json flag is enabled" do
- before do
- stub_feature_flags(grape_gitlab_json: true)
- end
-
- it "generates JSON" do
- expect(subject).to eq(result)
- end
-
- it "uses Gitlab::Json" do
- expect(Gitlab::Json).to receive(:dump).with(obj)
-
- subject
- end
+ it "generates JSON" do
+ expect(subject).to eq(result)
end
- context "grape_gitlab_json flag is disabled" do
- before do
- stub_feature_flags(grape_gitlab_json: false)
- end
-
- it "generates JSON" do
- expect(subject).to eq(result)
- end
+ it "uses Gitlab::Json" do
+ expect(Gitlab::Json).to receive(:dump).with(obj)
- it "uses Grape::Formatter::Json" do
- expect(Grape::Formatter::Json).to receive(:call).with(obj, env)
-
- subject
- end
+ subject
end
end
- context "oj is disabled" do
+ context "grape_gitlab_json flag is disabled" do
before do
- stub_feature_flags(oj_json: false)
+ stub_feature_flags(grape_gitlab_json: false)
end
it "generates JSON" do
diff --git a/spec/lib/gitlab/kubernetes/helm/api_spec.rb b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
index bcc95bdbf2b..e022f5bd912 100644
--- a/spec/lib/gitlab/kubernetes/helm/api_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/api_spec.rb
@@ -13,7 +13,7 @@ RSpec.describe Gitlab::Kubernetes::Helm::API do
let(:files) { {} }
let(:command) do
- Gitlab::Kubernetes::Helm::InstallCommand.new(
+ Gitlab::Kubernetes::Helm::V2::InstallCommand.new(
name: application_name,
chart: 'chart-name',
rbac: rbac,
@@ -142,7 +142,7 @@ RSpec.describe Gitlab::Kubernetes::Helm::API do
end
context 'with a service account' do
- let(:command) { Gitlab::Kubernetes::Helm::InitCommand.new(name: application_name, files: files, rbac: rbac) }
+ let(:command) { Gitlab::Kubernetes::Helm::V2::InitCommand.new(name: application_name, files: files, rbac: rbac) }
context 'rbac-enabled cluster' do
let(:rbac) { true }
diff --git a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
index 54e3289dd25..6d97790fc8b 100644
--- a/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/pod_spec.rb
@@ -4,75 +4,84 @@ require 'spec_helper'
RSpec.describe Gitlab::Kubernetes::Helm::Pod do
describe '#generate' do
- let(:app) { create(:clusters_applications_prometheus) }
- let(:command) { app.install_command }
- let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
- let(:service_account_name) { nil }
+ using RSpec::Parameterized::TableSyntax
- subject { described_class.new(command, namespace, service_account_name: service_account_name) }
+ where(:helm_major_version, :expected_helm_version, :expected_command_env) do
+ 2 | '2.16.9' | [:TILLER_NAMESPACE]
+ 3 | '3.2.4' | nil
+ end
- context 'with a command' do
- it 'generates a Kubeclient::Resource' do
- expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
- end
+ with_them do
+ let(:cluster) { create(:cluster, helm_major_version: helm_major_version) }
+ let(:app) { create(:clusters_applications_prometheus, cluster: cluster) }
+ let(:command) { app.install_command }
+ let(:namespace) { Gitlab::Kubernetes::Helm::NAMESPACE }
+ let(:service_account_name) { nil }
- it 'generates the appropriate metadata' do
- metadata = subject.generate.metadata
- expect(metadata.name).to eq("install-#{app.name}")
- expect(metadata.namespace).to eq('gitlab-managed-apps')
- expect(metadata.labels['gitlab.org/action']).to eq('install')
- expect(metadata.labels['gitlab.org/application']).to eq(app.name)
- end
+ subject { described_class.new(command, namespace, service_account_name: service_account_name) }
- it 'generates a container spec' do
- spec = subject.generate.spec
- expect(spec.containers.count).to eq(1)
- end
+ context 'with a command' do
+ it 'generates a Kubeclient::Resource' do
+ expect(subject.generate).to be_a_kind_of(Kubeclient::Resource)
+ end
- it 'generates the appropriate 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.9-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"])
- expect(container.args).to match_array(["-c", "$(COMMAND_SCRIPT)"])
- end
+ it 'generates the appropriate metadata' do
+ metadata = subject.generate.metadata
+ expect(metadata.name).to eq("install-#{app.name}")
+ expect(metadata.namespace).to eq('gitlab-managed-apps')
+ expect(metadata.labels['gitlab.org/action']).to eq('install')
+ expect(metadata.labels['gitlab.org/application']).to eq(app.name)
+ end
- it 'includes a never restart policy' do
- spec = subject.generate.spec
- expect(spec.restartPolicy).to eq('Never')
- end
+ it 'generates a container spec' do
+ spec = subject.generate.spec
+ expect(spec.containers.count).to eq(1)
+ end
- it 'includes volumes for the container' do
- container = subject.generate.spec.containers.first
- expect(container.volumeMounts.first['name']).to eq('configuration-volume')
- expect(container.volumeMounts.first['mountPath']).to eq("/data/helm/#{app.name}/config")
- end
+ it 'generates the appropriate specifications for the container' do
+ container = subject.generate.spec.containers.first
+ expect(container.name).to eq('helm')
+ expect(container.image).to eq("registry.gitlab.com/gitlab-org/cluster-integration/helm-install-image/releases/#{expected_helm_version}-kube-1.13.12-alpine-3.12")
+ expect(container.env.map(&:name)).to include(:HELM_VERSION, :COMMAND_SCRIPT, *expected_command_env)
+ expect(container.command).to match_array(["/bin/sh"])
+ expect(container.args).to match_array(["-c", "$(COMMAND_SCRIPT)"])
+ end
- it 'includes a volume inside the specification' do
- spec = subject.generate.spec
- expect(spec.volumes.first['name']).to eq('configuration-volume')
- end
+ it 'includes a never restart policy' do
+ spec = subject.generate.spec
+ expect(spec.restartPolicy).to eq('Never')
+ end
- it 'mounts configMap specification in the volume' do
- volume = subject.generate.spec.volumes.first
- expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
- expect(volume.configMap['items'].first['key']).to eq(:'values.yaml')
- expect(volume.configMap['items'].first['path']).to eq(:'values.yaml')
- end
+ it 'includes volumes for the container' do
+ container = subject.generate.spec.containers.first
+ expect(container.volumeMounts.first['name']).to eq('configuration-volume')
+ expect(container.volumeMounts.first['mountPath']).to eq("/data/helm/#{app.name}/config")
+ end
- it 'has no serviceAccountName' do
- spec = subject.generate.spec
- expect(spec.serviceAccountName).to be_nil
- end
+ it 'includes a volume inside the specification' do
+ spec = subject.generate.spec
+ expect(spec.volumes.first['name']).to eq('configuration-volume')
+ end
- context 'with a service_account_name' do
- let(:service_account_name) { 'sa' }
+ it 'mounts configMap specification in the volume' do
+ volume = subject.generate.spec.volumes.first
+ expect(volume.configMap['name']).to eq("values-content-configuration-#{app.name}")
+ expect(volume.configMap['items'].first['key']).to eq(:'values.yaml')
+ expect(volume.configMap['items'].first['path']).to eq(:'values.yaml')
+ end
- it 'uses the serviceAccountName provided' do
+ it 'has no serviceAccountName' do
spec = subject.generate.spec
- expect(spec.serviceAccountName).to eq(service_account_name)
+ expect(spec.serviceAccountName).to be_nil
+ end
+
+ context 'with a service_account_name' do
+ let(:service_account_name) { 'sa' }
+
+ it 'uses the serviceAccountName provided' do
+ spec = subject.generate.spec
+ expect(spec.serviceAccountName).to eq(service_account_name)
+ end
end
end
end
diff --git a/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb
new file mode 100644
index 00000000000..3d2b36b9094
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/v2/base_command_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kubernetes::Helm::V2::BaseCommand do
+ subject(:base_command) do
+ test_class.new(rbac)
+ end
+
+ let(:application) { create(:clusters_applications_helm) }
+ let(:rbac) { false }
+
+ let(:test_class) do
+ Class.new(described_class) do
+ def initialize(rbac)
+ super(
+ name: 'test-class-name',
+ rbac: rbac,
+ files: { some: 'value' }
+ )
+ end
+ end
+ end
+
+ describe 'HELM_VERSION' do
+ subject { described_class::HELM_VERSION }
+
+ it { is_expected.to match /^2\.\d+\.\d+$/ }
+ end
+
+ describe '#env' do
+ subject { base_command.env }
+
+ it { is_expected.to include(TILLER_NAMESPACE: 'gitlab-managed-apps') }
+ end
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) { '' }
+ end
+
+ describe '#pod_name' do
+ subject { base_command.pod_name }
+
+ it { is_expected.to eq('install-test-class-name') }
+ end
+
+ it_behaves_like 'helm command' do
+ let(:command) { base_command }
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb
index b446c5e1149..a3f0fd9eb9b 100644
--- a/spec/lib/gitlab/kubernetes/helm/certificate_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/certificate_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::Certificate do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::Certificate do
describe '.generate_root' do
subject { described_class.generate_root }
diff --git a/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb
index ff2c2d76f22..4a3a41dba4a 100644
--- a/spec/lib/gitlab/kubernetes/helm/delete_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/delete_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::DeleteCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::DeleteCommand do
subject(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
let(:app_name) { 'app-name' }
diff --git a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb
index d538ed12a07..8ae78ada15c 100644
--- a/spec/lib/gitlab/kubernetes/helm/init_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/init_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::InitCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::InitCommand do
subject(:init_command) { described_class.new(name: application.name, files: files, rbac: rbac) }
let(:application) { create(:clusters_applications_helm) }
diff --git a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb
index 6ed7323c96f..250d1a82e7a 100644
--- a/spec/lib/gitlab/kubernetes/helm/install_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/install_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::InstallCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::InstallCommand do
subject(:install_command) do
described_class.new(
name: 'app-name',
@@ -147,37 +147,6 @@ RSpec.describe Gitlab::Kubernetes::Helm::InstallCommand do
end
end
- context 'when there is no ca.pem file' do
- let(:files) { { 'file.txt': 'some content' } }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_install_command}
- EOS
- end
-
- let(:helm_install_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --install
- --atomic
- --cleanup-on-fail
- --reset-values
- --version 1.2.3
- --set rbac.create\\=false,rbac.enabled\\=false
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
context 'when there is no version' do
let(:version) { nil }
diff --git a/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb
index 487a38f286d..98eb77d397c 100644
--- a/spec/lib/gitlab/kubernetes/helm/patch_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/patch_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::PatchCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::PatchCommand do
let(:files) { { 'ca.pem': 'some file content' } }
let(:repository) { 'https://repository.example.com' }
let(:rbac) { false }
@@ -69,33 +69,6 @@ RSpec.describe Gitlab::Kubernetes::Helm::PatchCommand do
end
end
- context 'when there is no ca.pem file' do
- let(:files) { { 'file.txt': 'some content' } }
-
- it_behaves_like 'helm command generator' do
- let(:commands) do
- <<~EOS
- export HELM_HOST="localhost:44134"
- tiller -listen ${HELM_HOST} -alsologtostderr &
- helm init --client-only
- helm repo add app-name https://repository.example.com
- helm repo update
- #{helm_upgrade_command}
- EOS
- end
-
- let(:helm_upgrade_command) do
- <<~EOS.squish
- helm upgrade app-name chart-name
- --reuse-values
- --version 1.2.3
- --namespace gitlab-managed-apps
- -f /data/helm/app-name/config/values.yaml
- EOS
- end
- end
- end
-
context 'when there is no version' do
let(:version) { nil }
diff --git a/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb
index 5a3ba59b8c0..9e580cea397 100644
--- a/spec/lib/gitlab/kubernetes/helm/reset_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v2/reset_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::ResetCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V2::ResetCommand do
subject(:reset_command) { described_class.new(name: name, rbac: rbac, files: files) }
let(:rbac) { true }
diff --git a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb
index a7abd6ab1bf..ad5ff13b4c9 100644
--- a/spec/lib/gitlab/kubernetes/helm/base_command_spec.rb
+++ b/spec/lib/gitlab/kubernetes/helm/v3/base_command_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Kubernetes::Helm::BaseCommand do
+RSpec.describe Gitlab::Kubernetes::Helm::V3::BaseCommand do
subject(:base_command) do
test_class.new(rbac)
end
@@ -11,7 +11,7 @@ RSpec.describe Gitlab::Kubernetes::Helm::BaseCommand do
let(:rbac) { false }
let(:test_class) do
- Class.new(Gitlab::Kubernetes::Helm::BaseCommand) do
+ Class.new(described_class) do
def initialize(rbac)
super(
name: 'test-class-name',
@@ -22,6 +22,12 @@ RSpec.describe Gitlab::Kubernetes::Helm::BaseCommand do
end
end
+ describe 'HELM_VERSION' do
+ subject { described_class::HELM_VERSION }
+
+ it { is_expected.to match /^3\.\d+\.\d+$/ }
+ end
+
it_behaves_like 'helm command generator' do
let(:commands) { '' }
end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb
new file mode 100644
index 00000000000..63e7a8d2f25
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/v3/delete_command_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kubernetes::Helm::V3::DeleteCommand do
+ subject(:delete_command) { described_class.new(name: app_name, rbac: rbac, files: files) }
+
+ let(:app_name) { 'app-name' }
+ let(:rbac) { true }
+ let(:files) { {} }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm uninstall app-name --namespace gitlab-managed-apps
+ EOS
+ end
+ end
+
+ describe '#pod_name' do
+ subject { delete_command.pod_name }
+
+ it { is_expected.to eq('uninstall-app-name') }
+ end
+
+ it_behaves_like 'helm command' do
+ let(:command) { delete_command }
+ end
+
+ describe '#delete_command' do
+ it 'deletes the release' do
+ expect(subject.delete_command).to eq('helm uninstall app-name --namespace gitlab-managed-apps')
+ end
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb
new file mode 100644
index 00000000000..2bf1f713b3f
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/v3/install_command_spec.rb
@@ -0,0 +1,168 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kubernetes::Helm::V3::InstallCommand do
+ subject(:install_command) do
+ described_class.new(
+ name: 'app-name',
+ chart: 'chart-name',
+ rbac: rbac,
+ files: files,
+ version: version,
+ repository: repository,
+ preinstall: preinstall,
+ postinstall: postinstall
+ )
+ end
+
+ let(:files) { { 'ca.pem': 'some file content' } }
+ let(:repository) { 'https://repository.example.com' }
+ let(:rbac) { false }
+ let(:version) { '1.2.3' }
+ let(:preinstall) { nil }
+ let(:postinstall) { nil }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_install_comand}
+ EOS
+ end
+
+ let(:helm_install_comand) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --atomic
+ --cleanup-on-fail
+ --reset-values
+ --version 1.2.3
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+
+ context 'when rbac is true' do
+ let(:rbac) { true }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_install_command}
+ EOS
+ end
+
+ let(:helm_install_command) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --atomic
+ --cleanup-on-fail
+ --reset-values
+ --version 1.2.3
+ --set rbac.create\\=true,rbac.enabled\\=true
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+ end
+
+ context 'when there is a pre-install script' do
+ let(:preinstall) { ['/bin/date', '/bin/true'] }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ /bin/date
+ /bin/true
+ #{helm_install_command}
+ EOS
+ end
+
+ let(:helm_install_command) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --atomic
+ --cleanup-on-fail
+ --reset-values
+ --version 1.2.3
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+ end
+
+ context 'when there is a post-install script' do
+ let(:postinstall) { ['/bin/date', "/bin/false\n"] }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_install_command}
+ /bin/date
+ /bin/false
+ EOS
+ end
+
+ let(:helm_install_command) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --atomic
+ --cleanup-on-fail
+ --reset-values
+ --version 1.2.3
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+ end
+
+ context 'when there is no version' do
+ let(:version) { nil }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_install_command}
+ EOS
+ end
+
+ let(:helm_install_command) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --install
+ --atomic
+ --cleanup-on-fail
+ --reset-values
+ --set rbac.create\\=false,rbac.enabled\\=false
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+ end
+
+ it_behaves_like 'helm command' do
+ let(:command) { install_command }
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb b/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb
new file mode 100644
index 00000000000..2f22e0f2e77
--- /dev/null
+++ b/spec/lib/gitlab/kubernetes/helm/v3/patch_command_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Kubernetes::Helm::V3::PatchCommand do
+ let(:files) { { 'ca.pem': 'some file content' } }
+ let(:repository) { 'https://repository.example.com' }
+ let(:rbac) { false }
+ let(:version) { '1.2.3' }
+
+ subject(:patch_command) do
+ described_class.new(
+ name: 'app-name',
+ chart: 'chart-name',
+ rbac: rbac,
+ files: files,
+ version: version,
+ repository: repository
+ )
+ end
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_upgrade_comand}
+ EOS
+ end
+
+ let(:helm_upgrade_comand) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --reuse-values
+ --version 1.2.3
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+
+ context 'when rbac is true' do
+ let(:rbac) { true }
+
+ it_behaves_like 'helm command generator' do
+ let(:commands) do
+ <<~EOS
+ helm repo add app-name https://repository.example.com
+ helm repo update
+ #{helm_upgrade_command}
+ EOS
+ end
+
+ let(:helm_upgrade_command) do
+ <<~EOS.squish
+ helm upgrade app-name chart-name
+ --reuse-values
+ --version 1.2.3
+ --namespace gitlab-managed-apps
+ -f /data/helm/app-name/config/values.yaml
+ EOS
+ end
+ end
+ end
+
+ context 'when there is no version' do
+ let(:version) { nil }
+
+ it { expect { patch_command }.to raise_error(ArgumentError, 'version is required') }
+ end
+
+ describe '#pod_name' do
+ subject { patch_command.pod_name }
+
+ it { is_expected.to eq 'install-app-name' }
+ end
+
+ it_behaves_like 'helm command' do
+ let(:command) { patch_command }
+ end
+end
diff --git a/spec/lib/gitlab/kubernetes/kube_client_spec.rb b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
index 7b6d143dda9..521f13dc9cc 100644
--- a/spec/lib/gitlab/kubernetes/kube_client_spec.rb
+++ b/spec/lib/gitlab/kubernetes/kube_client_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
case method_name
when /\A(get_|delete_)/
client.public_send(method_name)
- when /\A(create_|update_)/
+ when /\A(create_|update_|patch_)/
client.public_send(method_name, {})
else
raise "Unknown method name #{method_name}"
@@ -302,6 +302,8 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
:create_role,
:get_role,
:update_role,
+ :delete_role_binding,
+ :update_role_binding,
:update_cluster_role_binding
].each do |method|
describe "##{method}" do
@@ -375,6 +377,34 @@ RSpec.describe Gitlab::Kubernetes::KubeClient do
end
end
+ describe '#patch_ingress' do
+ let(:extensions_client) { client.extensions_client }
+ let(:networking_client) { client.networking_client }
+
+ include_examples 'redirection not allowed', 'patch_ingress'
+ include_examples 'dns rebinding not allowed', 'patch_ingress'
+
+ it 'delegates to the extensions client' do
+ expect(extensions_client).to receive(:patch_ingress)
+
+ client.patch_ingress
+ end
+
+ context 'extensions does not have ingress for Kubernetes 1.22+ clusters' do
+ before do
+ WebMock
+ .stub_request(:get, api_url + '/apis/extensions/v1beta1')
+ .to_return(kube_response(kube_1_22_extensions_v1beta1_discovery_body))
+ end
+
+ it 'delegates to the apps client' do
+ expect(networking_client).to receive(:patch_ingress)
+
+ client.patch_ingress
+ end
+ end
+ end
+
describe 'istio API group' do
let(:istio_client) { client.istio_client }
diff --git a/spec/lib/gitlab/legacy_github_import/importer_spec.rb b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
index 56d708a1e11..56074147854 100644
--- a/spec/lib/gitlab/legacy_github_import/importer_spec.rb
+++ b/spec/lib/gitlab/legacy_github_import/importer_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
allow_any_instance_of(Octokit::Client).to receive(:milestones).and_return([milestone, milestone])
allow_any_instance_of(Octokit::Client).to receive(:issues).and_return([issue1, issue2])
allow_any_instance_of(Octokit::Client).to receive(:pull_requests).and_return([pull_request, pull_request])
- allow_any_instance_of(Octokit::Client).to receive(:issues_comments).and_return([])
+ allow_any_instance_of(Octokit::Client).to receive(:issues_comments).and_raise(Octokit::NotFound)
allow_any_instance_of(Octokit::Client).to receive(:pull_requests_comments).and_return([])
allow_any_instance_of(Octokit::Client).to receive(:last_response).and_return(double(rels: { next: nil }))
allow_any_instance_of(Octokit::Client).to receive(:releases).and_return([release1, release2])
@@ -169,6 +169,7 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
errors: [
{ type: :label, url: "#{api_root}/repos/octocat/Hello-World/labels/bug", errors: "Validation failed: Title can't be blank, Title is invalid" },
{ type: :issue, url: "#{api_root}/repos/octocat/Hello-World/issues/1348", errors: "Validation failed: Title can't be blank" },
+ { type: :issues_comments, errors: 'Octokit::NotFound' },
{ type: :wiki, errors: "Gitlab::Git::CommandError" }
]
}
@@ -274,7 +275,7 @@ RSpec.describe Gitlab::LegacyGithubImport::Importer do
allow(project).to receive(:import_data).and_return(double(credentials: credentials))
expect(Gitlab::LegacyGithubImport::Client).to receive(:new).with(
credentials[:user],
- {}
+ **{}
)
subject.client
diff --git a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
index 631325402d9..1f7daaa308d 100644
--- a/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
+++ b/spec/lib/gitlab/metrics/requests_rack_middleware_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
+RSpec.describe Gitlab::Metrics::RequestsRackMiddleware, :aggregate_failures do
let(:app) { double('app') }
subject { described_class.new(app) }
@@ -21,20 +21,15 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
allow(app).to receive(:call).and_return([200, nil, nil])
end
- it 'increments requests count' do
- expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'unknown')
-
- subject.call(env)
- end
-
RSpec::Matchers.define :a_positive_execution_time do
match { |actual| actual > 0 }
end
- it 'measures execution time' do
+ it 'tracks request count and duration' do
+ expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown')
expect(described_class).to receive_message_chain(:http_request_duration_seconds, :observe).with({ method: 'get' }, a_positive_execution_time)
- Timecop.scale(3600) { subject.call(env) }
+ subject.call(env)
end
context 'request is a health check endpoint' do
@@ -44,15 +39,10 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
env['PATH_INFO'] = path
end
- it 'increments health endpoint counter rather than overall counter' do
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: 200)
- expect(described_class).not_to receive(:http_request_total)
-
- subject.call(env)
- end
-
- it 'does not record the request duration' do
+ it 'increments health endpoint counter rather than overall counter and does not record duration' do
expect(described_class).not_to receive(:http_request_duration_seconds)
+ expect(described_class).not_to receive(:http_requests_total)
+ expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: '200')
subject.call(env)
end
@@ -67,14 +57,9 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
env['PATH_INFO'] = path
end
- it 'increments overall counter rather than health endpoint counter' do
- expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'unknown')
+ it 'increments regular counters and tracks duration' do
+ expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown')
expect(described_class).not_to receive(:http_health_requests_total)
-
- subject.call(env)
- end
-
- it 'records the request duration' do
expect(described_class)
.to receive_message_chain(:http_request_duration_seconds, :observe)
.with({ method: 'get' }, a_positive_execution_time)
@@ -88,62 +73,91 @@ RSpec.describe Gitlab::Metrics::RequestsRackMiddleware do
context '@app.call throws exception' do
let(:http_request_duration_seconds) { double('http_request_duration_seconds') }
+ let(:http_requests_total) { double('http_requests_total') }
before do
allow(app).to receive(:call).and_raise(StandardError)
allow(described_class).to receive(:http_request_duration_seconds).and_return(http_request_duration_seconds)
+ allow(described_class).to receive(:http_requests_total).and_return(http_requests_total)
end
- it 'increments exceptions count' do
+ it 'tracks the correct metrics' do
expect(described_class).to receive_message_chain(:rack_uncaught_errors_count, :increment)
+ expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'unknown')
+ expect(described_class.http_request_duration_seconds).not_to receive(:observe)
expect { subject.call(env) }.to raise_error(StandardError)
end
+ end
- it 'increments requests count' do
- expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 'undefined', feature_category: 'unknown')
-
- expect { subject.call(env) }.to raise_error(StandardError)
- end
+ context 'feature category header' do
+ context 'when a feature category header is present' do
+ before do
+ allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => 'issue_tracking' }, nil])
+ end
- it "does't measure request execution time" do
- expect(described_class.http_request_duration_seconds).not_to receive(:increment)
+ it 'adds the feature category to the labels for http_requests_total' do
+ expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'issue_tracking')
+ expect(described_class).not_to receive(:http_health_requests_total)
- expect { subject.call(env) }.to raise_error(StandardError)
- end
- end
+ subject.call(env)
+ end
- context 'when a feature category header is present' do
- before do
- allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => 'issue_tracking' }, nil])
- end
+ it 'does not record a feature category for health check endpoints' do
+ env['PATH_INFO'] = '/-/liveness'
- it 'adds the feature category to the labels for http_request_total' do
- expect(described_class).to receive_message_chain(:http_request_total, :increment).with(method: 'get', status: 200, feature_category: 'issue_tracking')
+ expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: '200')
+ expect(described_class).not_to receive(:http_requests_total)
- subject.call(env)
+ subject.call(env)
+ end
end
- it 'does not record a feature category for health check endpoints' do
- env['PATH_INFO'] = '/-/liveness'
+ context 'when the feature category header is an empty string' do
+ before do
+ allow(app).to receive(:call).and_return([200, { described_class::FEATURE_CATEGORY_HEADER => '' }, nil])
+ end
- expect(described_class).to receive_message_chain(:http_health_requests_total, :increment).with(method: 'get', status: 200)
- expect(described_class).not_to receive(:http_request_total)
+ it 'sets the feature category to unknown' do
+ expect(described_class).to receive_message_chain(:http_requests_total, :increment).with(method: 'get', status: '200', feature_category: 'unknown')
+ expect(described_class).not_to receive(:http_health_requests_total)
- subject.call(env)
+ subject.call(env)
+ end
end
end
- describe '.initialize_http_request_duration_seconds' do
- it "sets labels" do
+ describe '.initialize_metrics', :prometheus do
+ it "sets labels for http_requests_total" do
expected_labels = []
- described_class::HTTP_METHODS.each do |method|
- expected_labels << { method: method }
+
+ described_class::HTTP_METHODS.each do |method, statuses|
+ statuses.each do |status|
+ described_class::FEATURE_CATEGORIES_TO_INITIALIZE.each do |feature_category|
+ expected_labels << { method: method.to_s, status: status.to_s, feature_category: feature_category.to_s }
+ end
+ end
end
- described_class.initialize_http_request_duration_seconds
+ described_class.initialize_metrics
+
+ expect(described_class.http_requests_total.values.keys).to contain_exactly(*expected_labels)
+ end
+
+ it 'sets labels for http_request_duration_seconds' do
+ expected_labels = described_class::HTTP_METHODS.keys.map { |method| { method: method } }
+
+ described_class.initialize_metrics
+
expect(described_class.http_request_duration_seconds.values.keys).to include(*expected_labels)
end
+
+ it 'has every label in config/feature_categories.yml' do
+ defaults = [described_class::FEATURE_CATEGORY_DEFAULT, 'not_owned']
+ feature_categories = YAML.load_file(Rails.root.join('config', 'feature_categories.yml')).map(&:strip) + defaults
+
+ expect(described_class::FEATURE_CATEGORIES_TO_INITIALIZE).to all(be_in(feature_categories))
+ end
end
end
end
diff --git a/spec/lib/gitlab/middleware/handle_malformed_strings_spec.rb b/spec/lib/gitlab/middleware/handle_malformed_strings_spec.rb
new file mode 100644
index 00000000000..e806f6478b7
--- /dev/null
+++ b/spec/lib/gitlab/middleware/handle_malformed_strings_spec.rb
@@ -0,0 +1,182 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require "rack/test"
+
+RSpec.describe Gitlab::Middleware::HandleMalformedStrings do
+ include GitHttpHelpers
+
+ let(:null_byte) { "\u0000" }
+ let(:escaped_null_byte) { "%00" }
+ let(:invalid_string) { "mal\xC0formed" }
+ let(:escaped_invalid_string) { "mal%c0formed" }
+ let(:error_400) { [400, { 'Content-Type' => 'text/plain' }, ['Bad Request']] }
+ let(:app) { double(:app) }
+
+ subject { described_class.new(app) }
+
+ before do
+ allow(app).to receive(:call) do |args|
+ args
+ end
+ end
+
+ def env_for(params = {})
+ Rack::MockRequest.env_for('/', { params: params })
+ end
+
+ context 'in the URL' do
+ it 'rejects null bytes' do
+ # We have to create the env separately or Rack::MockRequest complains about invalid URI
+ env = env_for
+ env['PATH_INFO'] = "/someplace/witha#{null_byte}nullbyte"
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it 'rejects escaped null bytes' do
+ # We have to create the env separately or Rack::MockRequest complains about invalid URI
+ env = env_for
+ env['PATH_INFO'] = "/someplace/withan#{escaped_null_byte}escaped nullbyte"
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it 'rejects malformed strings' do
+ # We have to create the env separately or Rack::MockRequest complains about invalid URI
+ env = env_for
+ env['PATH_INFO'] = "/someplace/with_an/#{invalid_string}"
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it 'rejects escaped malformed strings' do
+ # We have to create the env separately or Rack::MockRequest complains about invalid URI
+ env = env_for
+ env['PATH_INFO'] = "/someplace/with_an/#{escaped_invalid_string}"
+
+ expect(subject.call(env)).to eq error_400
+ end
+ end
+
+ context 'in authorization headers' do
+ let(:problematic_input) { null_byte }
+
+ shared_examples 'rejecting invalid input' do
+ it 'rejects problematic input in the password' do
+ env = env_for.merge(auth_env("username", "password#{problematic_input}encoded", nil))
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it 'rejects problematic input in the username' do
+ env = env_for.merge(auth_env("username#{problematic_input}", "passwordencoded", nil))
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it 'rejects problematic input in non-basic-auth tokens' do
+ env = env_for.merge('HTTP_AUTHORIZATION' => "GL-Geo hello#{problematic_input}world")
+
+ expect(subject.call(env)).to eq error_400
+ end
+ end
+
+ it_behaves_like 'rejecting invalid input' do
+ let(:problematic_input) { null_byte }
+ end
+
+ it_behaves_like 'rejecting invalid input' do
+ let(:problematic_input) { invalid_string }
+ end
+
+ it_behaves_like 'rejecting invalid input' do
+ let(:problematic_input) { "\xC3" }
+ end
+
+ it 'does not reject correct non-basic-auth tokens' do
+ # This token is known to include a null-byte when we were to try to decode it
+ # as Base64, while it wasn't encoded at such.
+ special_token = 'GL-Geo ta8KakZWpu0AcledQ6n0:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjoie1wic2NvcGVcIjpcImdlb19hcGlcIn0iLCJqdGkiOiIwYWFmNzVlYi1lNWRkLTRkZjEtODQzYi1lM2E5ODhhNDMwMzIiLCJpYXQiOjE2MDQ3MDI4NzUsIm5iZiI6MTYwNDcwMjg3MCwiZXhwIjoxNjA0NzAyOTM1fQ.NcgDipDyxSP5uSzxc01ylzH4GkTxJRflNNjT7U6fpg4'
+ expect(Base64.decode64(special_token)).to include(null_byte)
+
+ env = env_for.merge('HTTP_AUTHORIZATION' => special_token)
+
+ expect(subject.call(env)).not_to eq error_400
+ end
+ end
+
+ context 'in params' do
+ shared_examples_for 'checks params' do
+ it 'rejects bad params in a top level param' do
+ env = env_for(name: "null#{problematic_input}byte")
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it "rejects bad params for hashes with strings" do
+ env = env_for(name: { inner_key: "I am #{problematic_input} bad" })
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it "rejects bad params for arrays with strings" do
+ env = env_for(name: ["I am #{problematic_input} bad"])
+
+ expect(subject.call(env)).to eq error_400
+ end
+
+ it "rejects bad params for arrays containing hashes with string values" do
+ env = env_for(name: [
+ {
+ inner_key: "I am #{problematic_input} bad"
+ }
+ ])
+
+ expect(subject.call(env)).to eq error_400
+ end
+ end
+
+ context 'with null byte' do
+ let(:problematic_input) { null_byte }
+
+ it_behaves_like 'checks params'
+
+ it "gives up and does not reject too deeply nested params" do
+ env = env_for(name: [
+ {
+ inner_key: { deeper_key: [{ hash_inside_array_key: "I am #{problematic_input} bad" }] }
+ }
+ ])
+
+ expect(subject.call(env)).not_to eq error_400
+ end
+ end
+
+ context 'with malformed strings' do
+ it_behaves_like 'checks params' do
+ let(:problematic_input) { invalid_string }
+ end
+ end
+ end
+
+ context 'without problematic input' do
+ it "does not error for strings" do
+ env = env_for(name: "safe name")
+
+ expect(subject.call(env)).not_to eq error_400
+ end
+
+ it "does not error with no params" do
+ env = env_for
+
+ expect(subject.call(env)).not_to eq error_400
+ end
+ end
+
+ it 'does not modify the env' do
+ env = env_for
+
+ expect { subject.call(env) }.not_to change { env }
+ end
+end
diff --git a/spec/lib/gitlab/middleware/handle_null_bytes_spec.rb b/spec/lib/gitlab/middleware/handle_null_bytes_spec.rb
deleted file mode 100644
index 76a5174817e..00000000000
--- a/spec/lib/gitlab/middleware/handle_null_bytes_spec.rb
+++ /dev/null
@@ -1,88 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-require "rack/test"
-
-RSpec.describe Gitlab::Middleware::HandleNullBytes do
- let(:null_byte) { "\u0000" }
- let(:error_400) { [400, {}, ["Bad Request"]] }
- let(:app) { double(:app) }
-
- subject { described_class.new(app) }
-
- before do
- allow(app).to receive(:call) do |args|
- args
- end
- end
-
- def env_for(params = {})
- Rack::MockRequest.env_for('/', { params: params })
- end
-
- context 'with null bytes in params' do
- it 'rejects null bytes in a top level param' do
- env = env_for(name: "null#{null_byte}byte")
-
- expect(subject.call(env)).to eq error_400
- end
-
- it "responds with 400 BadRequest for hashes with strings" do
- env = env_for(name: { inner_key: "I am #{null_byte} bad" })
-
- expect(subject.call(env)).to eq error_400
- end
-
- it "responds with 400 BadRequest for arrays with strings" do
- env = env_for(name: ["I am #{null_byte} bad"])
-
- expect(subject.call(env)).to eq error_400
- end
-
- it "responds with 400 BadRequest for arrays containing hashes with string values" do
- env = env_for(name: [
- {
- inner_key: "I am #{null_byte} bad"
- }
- ])
-
- expect(subject.call(env)).to eq error_400
- end
-
- it "gives up and does not 400 with too deeply nested params" do
- env = env_for(name: [
- {
- inner_key: { deeper_key: [{ hash_inside_array_key: "I am #{null_byte} bad" }] }
- }
- ])
-
- expect(subject.call(env)).not_to eq error_400
- end
- end
-
- context 'without null bytes in params' do
- it "does not respond with a 400 for strings" do
- env = env_for(name: "safe name")
-
- expect(subject.call(env)).not_to eq error_400
- end
-
- it "does not respond with a 400 with no params" do
- env = env_for
-
- expect(subject.call(env)).not_to eq error_400
- end
- end
-
- context 'when disabled via env flag' do
- before do
- stub_env('REJECT_NULL_BYTES', '1')
- end
-
- it 'does not respond with a 400 no matter what' do
- env = env_for(name: "null#{null_byte}byte")
-
- expect(subject.call(env)).not_to eq error_400
- end
- end
-end
diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb
index 50dd38278b9..642b47fe087 100644
--- a/spec/lib/gitlab/middleware/read_only_spec.rb
+++ b/spec/lib/gitlab/middleware/read_only_spec.rb
@@ -3,209 +3,11 @@
require 'spec_helper'
RSpec.describe Gitlab::Middleware::ReadOnly do
- include Rack::Test::Methods
- using RSpec::Parameterized::TableSyntax
-
- let(:rack_stack) do
- rack = Rack::Builder.new do
- use ActionDispatch::Session::CacheStore
- use ActionDispatch::Flash
- end
-
- rack.run(subject)
- rack.to_app
- end
-
- let(:observe_env) do
- Module.new do
- attr_reader :env
-
- def call(env)
- @env = env
- super
- end
- end
- end
-
- let(:request) { Rack::MockRequest.new(rack_stack) }
-
- subject do
- described_class.new(fake_app).tap do |app|
- app.extend(observe_env)
- end
- end
-
- context 'normal requests to a read-only GitLab instance' do
- let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['OK']] } }
-
+ context 'when database is read-only' do
before do
allow(Gitlab::Database).to receive(:read_only?) { true }
end
- it 'expects PATCH requests to be disallowed' do
- response = request.patch('/test_request')
-
- expect(response).to be_redirect
- expect(subject).to disallow_request
- end
-
- it 'expects PUT requests to be disallowed' do
- response = request.put('/test_request')
-
- expect(response).to be_redirect
- expect(subject).to disallow_request
- end
-
- it 'expects POST requests to be disallowed' do
- response = request.post('/test_request')
-
- expect(response).to be_redirect
- expect(subject).to disallow_request
- end
-
- it 'expects a internal POST request to be allowed after a disallowed request' do
- response = request.post('/test_request')
-
- expect(response).to be_redirect
-
- response = request.post("/api/#{API::API.version}/internal")
-
- expect(response).not_to be_redirect
- end
-
- it 'expects DELETE requests to be disallowed' do
- response = request.delete('/test_request')
-
- expect(response).to be_redirect
- expect(subject).to disallow_request
- end
-
- it 'expects POST of new file that looks like an LFS batch url to be disallowed' do
- expect(Rails.application.routes).to receive(:recognize_path).and_call_original
- response = request.post('/root/gitlab-ce/new/master/app/info/lfs/objects/batch')
-
- expect(response).to be_redirect
- expect(subject).to disallow_request
- end
-
- it 'returns last_vistited_url for disallowed request' do
- response = request.post('/test_request')
-
- expect(response.location).to eq 'http://localhost/'
- end
-
- context 'whitelisted requests' do
- it 'expects a POST internal request to be allowed' do
- expect(Rails.application.routes).not_to receive(:recognize_path)
- response = request.post("/api/#{API::API.version}/internal")
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
- end
-
- it 'expects a graphql request to be allowed' do
- response = request.post("/api/graphql")
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
- end
-
- context 'relative URL is configured' do
- before do
- stub_config_setting(relative_url_root: '/gitlab')
- end
-
- it 'expects a graphql request to be allowed' do
- response = request.post("/gitlab/api/graphql")
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
- end
- end
-
- context 'sidekiq admin requests' do
- where(:mounted_at) do
- [
- '',
- '/',
- '/gitlab',
- '/gitlab/',
- '/gitlab/gitlab',
- '/gitlab/gitlab/'
- ]
- end
-
- with_them do
- before do
- stub_config_setting(relative_url_root: mounted_at)
- end
-
- it 'allows requests' do
- path = File.join(mounted_at, 'admin/sidekiq')
- response = request.post(path)
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
-
- response = request.get(path)
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
- end
- end
- end
-
- where(:description, :path) do
- 'LFS request to batch' | '/root/rouge.git/info/lfs/objects/batch'
- 'LFS request to locks verify' | '/root/rouge.git/info/lfs/locks/verify'
- 'LFS request to locks create' | '/root/rouge.git/info/lfs/locks'
- 'LFS request to locks unlock' | '/root/rouge.git/info/lfs/locks/1/unlock'
- 'request to git-upload-pack' | '/root/rouge.git/git-upload-pack'
- 'request to git-receive-pack' | '/root/rouge.git/git-receive-pack'
- end
-
- with_them do
- it "expects a POST #{description} URL to be allowed" do
- expect(Rails.application.routes).to receive(:recognize_path).and_call_original
- response = request.post(path)
-
- expect(response).not_to be_redirect
- expect(subject).not_to disallow_request
- end
- end
- end
- end
-
- context 'json requests to a read-only GitLab instance' do
- let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'application/json' }, ['OK']] } }
- let(:content_json) { { 'CONTENT_TYPE' => 'application/json' } }
-
- before do
- allow(Gitlab::Database).to receive(:read_only?) { true }
- end
-
- it 'expects PATCH requests to be disallowed' do
- response = request.patch('/test_request', content_json)
-
- expect(response).to disallow_request_in_json
- end
-
- it 'expects PUT requests to be disallowed' do
- response = request.put('/test_request', content_json)
-
- expect(response).to disallow_request_in_json
- end
-
- it 'expects POST requests to be disallowed' do
- response = request.post('/test_request', content_json)
-
- expect(response).to disallow_request_in_json
- end
-
- it 'expects DELETE requests to be disallowed' do
- response = request.delete('/test_request', content_json)
-
- expect(response).to disallow_request_in_json
- end
+ it_behaves_like 'write access for a read-only GitLab instance'
end
end
diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb
index a38dffcfce0..577d15b8495 100644
--- a/spec/lib/gitlab/omniauth_initializer_spec.rb
+++ b/spec/lib/gitlab/omniauth_initializer_spec.rb
@@ -101,33 +101,5 @@ RSpec.describe Gitlab::OmniauthInitializer do
subject.execute([google_config])
end
-
- it 'converts client_auth_method to a Symbol for openid_connect' do
- openid_connect_config = {
- 'name' => 'openid_connect',
- 'args' => { name: 'openid_connect', client_auth_method: 'basic' }
- }
-
- expect(devise_config).to receive(:omniauth).with(
- :openid_connect,
- { name: 'openid_connect', client_auth_method: :basic }
- )
-
- subject.execute([openid_connect_config])
- end
-
- it 'converts client_auth_method to a Symbol for strategy_class OpenIDConnect' do
- openid_connect_config = {
- 'name' => 'openid_connect',
- 'args' => { strategy_class: OmniAuth::Strategies::OpenIDConnect, client_auth_method: 'jwt_bearer' }
- }
-
- expect(devise_config).to receive(:omniauth).with(
- :openid_connect,
- { strategy_class: OmniAuth::Strategies::OpenIDConnect, client_auth_method: :jwt_bearer }
- )
-
- subject.execute([openid_connect_config])
- end
end
end
diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb
index 7cecc29afa4..f320b8a66e8 100644
--- a/spec/lib/gitlab/path_regex_spec.rb
+++ b/spec/lib/gitlab/path_regex_spec.rb
@@ -101,10 +101,15 @@ RSpec.describe Gitlab::PathRegex do
.concat(ee_top_level_words)
.concat(files_in_public)
.concat(Array(API::API.prefix.to_s))
+ .concat(sitemap_words)
.compact
.uniq
end
+ let(:sitemap_words) do
+ %w(sitemap sitemap.xml sitemap.xml.gz)
+ end
+
let(:ee_top_level_words) do
%w(unsubscribes v2)
end
@@ -172,7 +177,7 @@ RSpec.describe Gitlab::PathRegex do
# 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)
+ expect(described_class::TOP_LEVEL_ROUTES.size).to eq(44)
end
end
@@ -218,6 +223,8 @@ RSpec.describe Gitlab::PathRegex do
expect(subject).not_to match('admin/')
expect(subject).not_to match('api/')
expect(subject).not_to match('.well-known/')
+ expect(subject).not_to match('sitemap.xml/')
+ expect(subject).not_to match('sitemap.xml.gz/')
end
it 'accepts project wildcard routes' do
@@ -458,4 +465,34 @@ RSpec.describe Gitlab::PathRegex do
it_behaves_like 'invalid snippet routes'
end
+
+ describe '.container_image_regex' do
+ subject { described_class.container_image_regex }
+
+ it { is_expected.to match('gitlab-foss') }
+ it { is_expected.to match('gitlab_foss') }
+ it { is_expected.to match('gitlab-org/gitlab-foss') }
+ it { is_expected.to match('100px.com/100px.ruby') }
+
+ it 'only matches at most one slash' do
+ expect(subject.match('foo/bar/baz')[0]).to eq('foo/bar')
+ end
+
+ it 'does not match other non-word characters' do
+ expect(subject.match('ruby:2.7.0')[0]).to eq('ruby')
+ end
+ end
+
+ describe '.container_image_blob_sha_regex' do
+ subject { described_class.container_image_blob_sha_regex }
+
+ it { is_expected.to match('sha256:asdf1234567890ASDF') }
+ it { is_expected.to match('foo:123') }
+ it { is_expected.to match('a12bc3f590szp') }
+ it { is_expected.not_to match('') }
+
+ it 'does not match malicious characters' do
+ expect(subject.match('sha256:asdf1234%2f')[0]).to eq('sha256:asdf1234')
+ end
+ end
end
diff --git a/spec/lib/gitlab/quick_actions/extractor_spec.rb b/spec/lib/gitlab/quick_actions/extractor_spec.rb
index f4104b78d5c..61fffe3fb6b 100644
--- a/spec/lib/gitlab/quick_actions/extractor_spec.rb
+++ b/spec/lib/gitlab/quick_actions/extractor_spec.rb
@@ -264,6 +264,22 @@ RSpec.describe Gitlab::QuickActions::Extractor do
expect(msg).to eq 'Fixes #123'
end
+ it 'does not get confused if command comes before an inline code' do
+ msg = "/reopen\n`some inline code`\n/labels ~a\n`more inline code`"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq([['reopen'], ['labels', '~a']])
+ expect(msg).to eq "`some inline code`\n`more inline code`"
+ end
+
+ it 'does not get confused if command comes before a blockcode' do
+ msg = "/reopen\n```\nsome blockcode\n```\n/labels ~a\n```\nmore blockcode\n```"
+ msg, commands = extractor.extract_commands(msg)
+
+ expect(commands).to eq([['reopen'], ['labels', '~a']])
+ expect(msg).to eq "```\nsome blockcode\n```\n```\nmore blockcode\n```"
+ end
+
it 'does not extract commands inside a blockcode' do
msg = "Hello\r\n```\r\nThis is some text\r\n/close\r\n/assign @user\r\n```\r\n\r\nWorld"
expected = msg.delete("\r")
diff --git a/spec/lib/gitlab/redis/wrapper_spec.rb b/spec/lib/gitlab/redis/wrapper_spec.rb
index 283853ee863..ec233c022ee 100644
--- a/spec/lib/gitlab/redis/wrapper_spec.rb
+++ b/spec/lib/gitlab/redis/wrapper_spec.rb
@@ -26,6 +26,12 @@ RSpec.describe Gitlab::Redis::Wrapper do
end
end
+ describe '.version' do
+ it 'returns a version' do
+ expect(described_class.version).to be_present
+ end
+ end
+
describe '.instrumentation_class' do
it 'raises a NotImplementedError' do
expect(described_class).to receive(:instrumentation_class).and_call_original
diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb
index 0172defc75d..229d49868d4 100644
--- a/spec/lib/gitlab/reference_extractor_spec.rb
+++ b/spec/lib/gitlab/reference_extractor_spec.rb
@@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end
it 'returns all supported prefixes' do
- expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & *iteration:))
+ expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & [vulnerability: *iteration:))
end
it 'does not allow one prefix for multiple referables if not allowed specificly' do
diff --git a/spec/lib/gitlab/regex_spec.rb b/spec/lib/gitlab/regex_spec.rb
index 451526021c1..ebb37f45b95 100644
--- a/spec/lib/gitlab/regex_spec.rb
+++ b/spec/lib/gitlab/regex_spec.rb
@@ -248,6 +248,15 @@ RSpec.describe Gitlab::Regex do
it { is_expected.not_to match('!!()()') }
end
+ describe '.composer_dev_version_regex' do
+ subject { described_class.composer_dev_version_regex }
+
+ it { is_expected.to match('dev-master') }
+ it { is_expected.to match('1.x-dev') }
+ it { is_expected.not_to match('foobar') }
+ it { is_expected.not_to match('1.2.3') }
+ end
+
describe '.conan_recipe_component_regex' do
subject { described_class.conan_recipe_component_regex }
diff --git a/spec/lib/gitlab/relative_positioning/mover_spec.rb b/spec/lib/gitlab/relative_positioning/mover_spec.rb
index dafd34585a8..cbb15ae876d 100644
--- a/spec/lib/gitlab/relative_positioning/mover_spec.rb
+++ b/spec/lib/gitlab/relative_positioning/mover_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe RelativePositioning::Mover do
let_it_be(:one_free_space_set) do
indices.drop(1).map { |iid| create(:issue, project: one_free_space, iid: iid.succ) }
end
+
let_it_be(:three_sibs_set) do
[1, 2, 3].map { |iid| create(:issue, iid: iid, project: three_sibs) }
end
diff --git a/spec/lib/gitlab/repository_size_checker_spec.rb b/spec/lib/gitlab/repository_size_checker_spec.rb
index bd030d81d97..20c08da6c54 100644
--- a/spec/lib/gitlab/repository_size_checker_spec.rb
+++ b/spec/lib/gitlab/repository_size_checker_spec.rb
@@ -53,4 +53,10 @@ RSpec.describe Gitlab::RepositorySizeChecker do
describe '#exceeded_size' do
include_examples 'checker size exceeded'
end
+
+ describe '#additional_repo_storage_available?' do
+ it 'returns false' do
+ expect(subject.additional_repo_storage_available?).to eq(false)
+ end
+ end
end
diff --git a/spec/lib/gitlab/repository_size_error_message_spec.rb b/spec/lib/gitlab/repository_size_error_message_spec.rb
index 53b5ed5518f..78504d201d4 100644
--- a/spec/lib/gitlab/repository_size_error_message_spec.rb
+++ b/spec/lib/gitlab/repository_size_error_message_spec.rb
@@ -53,8 +53,18 @@ RSpec.describe Gitlab::RepositorySizeErrorMessage do
end
describe '#new_changes_error' do
- it 'returns the correct message' do
- expect(message.new_changes_error).to eq("Your push to this repository would cause it to exceed the size limit of 10 MB so it has been rejected. #{message.more_info_message}")
+ context 'when additional repo storage is available' do
+ it 'returns the correct message' do
+ allow(checker).to receive(:additional_repo_storage_available?).and_return(true)
+
+ expect(message.new_changes_error).to eq('Your push to this repository has been rejected because it would exceed storage limits. Please contact your GitLab administrator for more information.')
+ end
+ end
+
+ context 'when no additional repo storage is available' do
+ it 'returns the correct message' do
+ expect(message.new_changes_error).to eq("Your push to this repository would cause it to exceed the size limit of 10 MB so it has been rejected. #{message.more_info_message}")
+ end
end
end
end
diff --git a/spec/lib/gitlab/robots_txt/parser_spec.rb b/spec/lib/gitlab/robots_txt/parser_spec.rb
index bb88003ce20..f4e97e5e897 100644
--- a/spec/lib/gitlab/robots_txt/parser_spec.rb
+++ b/spec/lib/gitlab/robots_txt/parser_spec.rb
@@ -14,8 +14,13 @@ RSpec.describe Gitlab::RobotsTxt::Parser do
<<~TXT
User-Agent: *
Disallow: /autocomplete/users
- Disallow: /search
+ disallow: /search
Disallow: /api
+ Allow: /users
+ Disallow: /help
+ allow: /help
+ Disallow: /test$
+ Disallow: /ex$mple$
TXT
end
@@ -28,6 +33,12 @@ RSpec.describe Gitlab::RobotsTxt::Parser do
'/api/grapql' | true
'/api/index.html' | true
'/projects' | false
+ '/users' | false
+ '/help' | false
+ '/test' | true
+ '/testfoo' | false
+ '/ex$mple' | true
+ '/ex$mplefoo' | false
end
with_them do
@@ -47,6 +58,7 @@ RSpec.describe Gitlab::RobotsTxt::Parser do
Disallow: /*/*.git
Disallow: /*/archive/
Disallow: /*/repository/archive*
+ Allow: /*/repository/archive/foo
TXT
end
@@ -61,6 +73,7 @@ RSpec.describe Gitlab::RobotsTxt::Parser do
'/projects' | false
'/git' | false
'/projects/git' | false
+ '/project/repository/archive/foo' | false
end
with_them do
diff --git a/spec/lib/gitlab/search/sort_options_spec.rb b/spec/lib/gitlab/search/sort_options_spec.rb
new file mode 100644
index 00000000000..2044fdfc894
--- /dev/null
+++ b/spec/lib/gitlab/search/sort_options_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'gitlab/search/sort_options'
+
+RSpec.describe ::Gitlab::Search::SortOptions do
+ describe '.sort_and_direction' do
+ context 'using order_by and sort' do
+ it 'returns matched options' do
+ expect(described_class.sort_and_direction('created_at', 'asc')).to eq(:created_at_asc)
+ expect(described_class.sort_and_direction('created_at', 'desc')).to eq(:created_at_desc)
+ end
+ end
+
+ context 'using just sort' do
+ it 'returns matched options' do
+ expect(described_class.sort_and_direction(nil, 'created_asc')).to eq(:created_at_asc)
+ expect(described_class.sort_and_direction(nil, 'created_desc')).to eq(:created_at_desc)
+ end
+ end
+
+ context 'when unknown option' do
+ it 'returns unknown' do
+ expect(described_class.sort_and_direction(nil, 'foo_asc')).to eq(:unknown)
+ expect(described_class.sort_and_direction(nil, 'bar_desc')).to eq(:unknown)
+ expect(described_class.sort_and_direction(nil, 'created_bar')).to eq(:unknown)
+
+ expect(described_class.sort_and_direction('created_at', 'foo')).to eq(:unknown)
+ expect(described_class.sort_and_direction('foo', 'desc')).to eq(:unknown)
+ expect(described_class.sort_and_direction('created_at', nil)).to eq(:unknown)
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
index cf165d1770b..74834fb9014 100644
--- a/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
+++ b/spec/lib/gitlab/sidekiq_cluster/cli_spec.rb
@@ -108,101 +108,114 @@ RSpec.describe Gitlab::SidekiqCluster::CLI do
end
end
- context 'with --experimental-queue-selector' do
- where do
- {
- 'memory-bound queues' => {
- query: 'resource_boundary=memory',
- included_queues: %w(project_export),
- excluded_queues: %w(merge)
- },
- 'memory- or CPU-bound queues' => {
- query: 'resource_boundary=memory,cpu',
- included_queues: %w(auto_merge:auto_merge_process project_export),
- excluded_queues: %w(merge)
- },
- 'high urgency CI queues' => {
- query: 'feature_category=continuous_integration&urgency=high',
- included_queues: %w(pipeline_cache:expire_job_cache pipeline_cache:expire_pipeline_cache),
- excluded_queues: %w(merge)
- },
- 'CPU-bound high urgency CI queues' => {
- query: 'feature_category=continuous_integration&urgency=high&resource_boundary=cpu',
- included_queues: %w(pipeline_cache:expire_pipeline_cache),
- excluded_queues: %w(pipeline_cache:expire_job_cache merge)
- },
- 'CPU-bound high urgency non-CI queues' => {
- query: 'feature_category!=continuous_integration&urgency=high&resource_boundary=cpu',
- included_queues: %w(new_issue),
- excluded_queues: %w(pipeline_cache:expire_pipeline_cache)
- },
- 'CI and SCM queues' => {
- query: 'feature_category=continuous_integration|feature_category=source_code_management',
- included_queues: %w(pipeline_cache:expire_job_cache merge),
- excluded_queues: %w(mailers)
- }
- }
+ # Remove with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
+ context 'with --queue-selector and --experimental-queue-selector' do
+ it 'errors' do
+ expect(Gitlab::SidekiqCluster).not_to receive(:start)
+
+ expect { cli.run(%w(--queue-selector name=foo --experimental-queue-selector name=bar)) }
+ .to raise_error(described_class::CommandError)
end
+ end
- with_them do
- it 'expands queues by attributes' do
- expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts|
- expect(opts).to eq(default_options)
- expect(queues.first).to include(*included_queues)
- expect(queues.first).not_to include(*excluded_queues)
+ # Simplify with https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/646
+ ['--queue-selector', '--experimental-queue-selector'].each do |flag|
+ context "with #{flag}" do
+ where do
+ {
+ 'memory-bound queues' => {
+ query: 'resource_boundary=memory',
+ included_queues: %w(project_export),
+ excluded_queues: %w(merge)
+ },
+ 'memory- or CPU-bound queues' => {
+ query: 'resource_boundary=memory,cpu',
+ included_queues: %w(auto_merge:auto_merge_process project_export),
+ excluded_queues: %w(merge)
+ },
+ 'high urgency CI queues' => {
+ query: 'feature_category=continuous_integration&urgency=high',
+ included_queues: %w(pipeline_cache:expire_job_cache pipeline_cache:expire_pipeline_cache),
+ excluded_queues: %w(merge)
+ },
+ 'CPU-bound high urgency CI queues' => {
+ query: 'feature_category=continuous_integration&urgency=high&resource_boundary=cpu',
+ included_queues: %w(pipeline_cache:expire_pipeline_cache),
+ excluded_queues: %w(pipeline_cache:expire_job_cache merge)
+ },
+ 'CPU-bound high urgency non-CI queues' => {
+ query: 'feature_category!=continuous_integration&urgency=high&resource_boundary=cpu',
+ included_queues: %w(new_issue),
+ excluded_queues: %w(pipeline_cache:expire_pipeline_cache)
+ },
+ 'CI and SCM queues' => {
+ query: 'feature_category=continuous_integration|feature_category=source_code_management',
+ included_queues: %w(pipeline_cache:expire_job_cache merge),
+ excluded_queues: %w(mailers)
+ }
+ }
+ end
+
+ with_them do
+ it 'expands queues by attributes' do
+ expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts|
+ expect(opts).to eq(default_options)
+ expect(queues.first).to include(*included_queues)
+ expect(queues.first).not_to include(*excluded_queues)
+
+ []
+ end
- []
+ cli.run(%W(#{flag} #{query}))
end
- cli.run(%W(--experimental-queue-selector #{query}))
- end
+ it 'works when negated' do
+ expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts|
+ expect(opts).to eq(default_options)
+ expect(queues.first).not_to include(*included_queues)
+ expect(queues.first).to include(*excluded_queues)
- it 'works when negated' do
- expect(Gitlab::SidekiqCluster).to receive(:start) do |queues, opts|
- expect(opts).to eq(default_options)
- expect(queues.first).not_to include(*included_queues)
- expect(queues.first).to include(*excluded_queues)
+ []
+ end
- []
+ cli.run(%W(--negate #{flag} #{query}))
end
-
- cli.run(%W(--negate --experimental-queue-selector #{query}))
end
- end
- it 'expands multiple queue groups correctly' do
- expect(Gitlab::SidekiqCluster)
- .to receive(:start)
- .with([['chat_notification'], ['project_export']], default_options)
- .and_return([])
+ it 'expands multiple queue groups correctly' do
+ expect(Gitlab::SidekiqCluster)
+ .to receive(:start)
+ .with([['chat_notification'], ['project_export']], default_options)
+ .and_return([])
- cli.run(%w(--experimental-queue-selector feature_category=chatops&has_external_dependencies=true resource_boundary=memory&feature_category=importers))
- end
+ cli.run(%W(#{flag} feature_category=chatops&has_external_dependencies=true resource_boundary=memory&feature_category=importers))
+ end
- it 'allows the special * selector' do
- worker_queues = %w(foo bar baz)
+ it 'allows the special * selector' do
+ worker_queues = %w(foo bar baz)
- expect(Gitlab::SidekiqConfig::CliMethods)
- .to receive(:worker_queues).and_return(worker_queues)
+ expect(Gitlab::SidekiqConfig::CliMethods)
+ .to receive(:worker_queues).and_return(worker_queues)
- expect(Gitlab::SidekiqCluster)
- .to receive(:start).with([worker_queues], default_options)
+ expect(Gitlab::SidekiqCluster)
+ .to receive(:start).with([worker_queues], default_options)
- cli.run(%w(--experimental-queue-selector *))
- end
+ cli.run(%W(#{flag} *))
+ end
- it 'errors when the selector matches no queues' do
- expect(Gitlab::SidekiqCluster).not_to receive(:start)
+ it 'errors when the selector matches no queues' do
+ expect(Gitlab::SidekiqCluster).not_to receive(:start)
- expect { cli.run(%w(--experimental-queue-selector has_external_dependencies=true&has_external_dependencies=false)) }
- .to raise_error(described_class::CommandError)
- end
+ expect { cli.run(%W(#{flag} has_external_dependencies=true&has_external_dependencies=false)) }
+ .to raise_error(described_class::CommandError)
+ end
- it 'errors on an invalid query multiple queue groups correctly' do
- expect(Gitlab::SidekiqCluster).not_to receive(:start)
+ it 'errors on an invalid query multiple queue groups correctly' do
+ expect(Gitlab::SidekiqCluster).not_to receive(:start)
- expect { cli.run(%w(--experimental-queue-selector unknown_field=chatops)) }
- .to raise_error(Gitlab::SidekiqConfig::CliMethods::QueryError)
+ expect { cli.run(%W(#{flag} unknown_field=chatops)) }
+ .to raise_error(Gitlab::SidekiqConfig::CliMethods::QueryError)
+ end
end
end
end
diff --git a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
index ad106837c47..b99a5352717 100644
--- a/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
+++ b/spec/lib/gitlab/sidekiq_logging/structured_logger_spec.rb
@@ -119,6 +119,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end
context 'with SIDEKIQ_LOG_ARGUMENTS disabled' do
+ before do
+ stub_env('SIDEKIQ_LOG_ARGUMENTS', '0')
+ end
+
it 'logs start and end of job without args' do
Timecop.freeze(timestamp) do
expect(logger).to receive(:info).with(start_payload.except('args')).ordered
@@ -150,8 +154,8 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
it 'logs with scheduling latency' do
Timecop.freeze(timestamp) do
- expect(logger).to receive(:info).with(start_payload.except('args')).ordered
- expect(logger).to receive(:info).with(end_payload.except('args')).ordered
+ expect(logger).to receive(:info).with(start_payload).ordered
+ expect(logger).to receive(:info).with(end_payload).ordered
expect(subject).to receive(:log_job_start).and_call_original
expect(subject).to receive(:log_job_done).and_call_original
@@ -173,12 +177,12 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end
let(:expected_end_payload) do
- end_payload.except('args').merge(timing_data)
+ end_payload.merge(timing_data)
end
it 'logs with Gitaly and Rugged timing data' do
Timecop.freeze(timestamp) do
- expect(logger).to receive(:info).with(start_payload.except('args')).ordered
+ expect(logger).to receive(:info).with(start_payload).ordered
expect(logger).to receive(:info).with(expected_end_payload).ordered
subject.call(job, 'test_queue') do
@@ -194,10 +198,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
allow(Process).to receive(:clock_gettime).and_call_original
end
- let(:expected_start_payload) { start_payload.except('args') }
+ let(:expected_start_payload) { start_payload }
let(:expected_end_payload) do
- end_payload.except('args').merge('cpu_s' => a_value >= 0)
+ end_payload.merge('cpu_s' => a_value >= 0)
end
let(:expected_end_payload_with_db) do
@@ -228,10 +232,10 @@ RSpec.describe Gitlab::SidekiqLogging::StructuredLogger do
end
context 'when there is extra metadata set for the done log' do
- let(:expected_start_payload) { start_payload.except('args') }
+ let(:expected_start_payload) { start_payload }
let(:expected_end_payload) do
- end_payload.except('args').merge("#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1" => 15, "#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2" => 16)
+ end_payload.merge("#{ApplicationWorker::LOGGING_EXTRA_KEY}.key1" => 15, "#{ApplicationWorker::LOGGING_EXTRA_KEY}.key2" => 16)
end
it 'logs it in the done log' do
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/client_spec.rb
index 98350fb9b8e..4d12e4b3f6f 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/client_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/client_spec.rb
@@ -3,79 +3,84 @@
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Client, :clean_gitlab_redis_queues do
- let(:worker_class) do
- Class.new do
- def self.name
- 'TestDeduplicationWorker'
- end
+ shared_context 'deduplication worker class' do |strategy, including_scheduled|
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ 'TestDeduplicationWorker'
+ end
+
+ include ApplicationWorker
+
+ deduplicate strategy, including_scheduled: including_scheduled
- include ApplicationWorker
+ include ApplicationWorker
- def perform(*args)
+ def perform(*args)
+ end
end
end
- end
- before do
- stub_const('TestDeduplicationWorker', worker_class)
+ before do
+ stub_const('TestDeduplicationWorker', worker_class)
+ end
end
- describe '#call' do
- it 'adds a correct duplicate tag to the jobs', :aggregate_failures do
- TestDeduplicationWorker.bulk_perform_async([['args1'], ['args2'], ['args1']])
+ shared_examples 'client duplicate job' do |strategy|
+ describe '#call' do
+ include_context 'deduplication worker class', strategy, false
- job1, job2, job3 = TestDeduplicationWorker.jobs
-
- expect(job1['duplicate-of']).to be_nil
- expect(job2['duplicate-of']).to be_nil
- expect(job3['duplicate-of']).to eq(job1['jid'])
- end
-
- context 'without scheduled deduplication' do
- it "does not mark a job that's scheduled in the future as a duplicate" do
- TestDeduplicationWorker.perform_async('args1')
- TestDeduplicationWorker.perform_at(1.day.from_now, 'args1')
- TestDeduplicationWorker.perform_in(3.hours, 'args1')
+ it 'adds a correct duplicate tag to the jobs', :aggregate_failures do
+ TestDeduplicationWorker.bulk_perform_async([['args1'], ['args2'], ['args1']])
- duplicates = TestDeduplicationWorker.jobs.map { |job| job['duplicate-of'] }
+ job1, job2, job3 = TestDeduplicationWorker.jobs
- expect(duplicates).to all(be_nil)
+ expect(job1['duplicate-of']).to be_nil
+ expect(job2['duplicate-of']).to be_nil
+ expect(job3['duplicate-of']).to eq(job1['jid'])
end
- end
-
- context 'with scheduled deduplication' do
- let(:scheduled_worker_class) do
- Class.new do
- def self.name
- 'TestDeduplicationWorker'
- end
- include ApplicationWorker
+ context 'without scheduled deduplication' do
+ it "does not mark a job that's scheduled in the future as a duplicate" do
+ TestDeduplicationWorker.perform_async('args1')
+ TestDeduplicationWorker.perform_at(1.day.from_now, 'args1')
+ TestDeduplicationWorker.perform_in(3.hours, 'args1')
- deduplicate :until_executing, including_scheduled: true
+ duplicates = TestDeduplicationWorker.jobs.map { |job| job['duplicate-of'] }
- def perform(*args)
- end
+ expect(duplicates).to all(be_nil)
end
end
- before do
- stub_const('TestDeduplicationWorker', scheduled_worker_class)
- end
+ context 'with scheduled deduplication' do
+ include_context 'deduplication worker class', strategy, true
- it 'adds a correct duplicate tag to the jobs', :aggregate_failures do
- TestDeduplicationWorker.perform_async('args1')
- TestDeduplicationWorker.perform_at(1.day.from_now, 'args1')
- TestDeduplicationWorker.perform_in(3.hours, 'args1')
- TestDeduplicationWorker.perform_in(3.hours, 'args2')
+ before do
+ stub_const('TestDeduplicationWorker', worker_class)
+ end
- job1, job2, job3, job4 = TestDeduplicationWorker.jobs
+ it 'adds a correct duplicate tag to the jobs', :aggregate_failures do
+ TestDeduplicationWorker.perform_async('args1')
+ TestDeduplicationWorker.perform_at(1.day.from_now, 'args1')
+ TestDeduplicationWorker.perform_in(3.hours, 'args1')
+ TestDeduplicationWorker.perform_in(3.hours, 'args2')
- expect(job1['duplicate-of']).to be_nil
- expect(job2['duplicate-of']).to eq(job1['jid'])
- expect(job3['duplicate-of']).to eq(job1['jid'])
- expect(job4['duplicate-of']).to be_nil
+ job1, job2, job3, job4 = TestDeduplicationWorker.jobs
+
+ expect(job1['duplicate-of']).to be_nil
+ expect(job2['duplicate-of']).to eq(job1['jid'])
+ expect(job3['duplicate-of']).to eq(job1['jid'])
+ expect(job4['duplicate-of']).to be_nil
+ end
end
end
end
+
+ context 'with until_executing strategy' do
+ it_behaves_like 'client duplicate job', :until_executing
+ end
+
+ context 'with until_executed strategy' do
+ it_behaves_like 'client duplicate job', :until_executed
+ end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
index 3f75d867936..09548d21106 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/server_spec.rb
@@ -3,39 +3,71 @@
require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Server, :clean_gitlab_redis_queues do
- let(:worker_class) do
- Class.new do
- def self.name
- 'TestDeduplicationWorker'
+ shared_context 'server duplicate job' do |strategy|
+ let(:worker_class) do
+ Class.new do
+ def self.name
+ 'TestDeduplicationWorker'
+ end
+
+ include ApplicationWorker
+
+ deduplicate strategy
+
+ def perform(*args)
+ self.class.work
+ end
+
+ def self.work
+ end
end
+ end
- include ApplicationWorker
+ before do
+ stub_const('TestDeduplicationWorker', worker_class)
+ end
- def perform(*args)
+ around do |example|
+ with_sidekiq_server_middleware do |chain|
+ chain.add described_class
+ Sidekiq::Testing.inline! { example.run }
end
end
end
- before do
- stub_const('TestDeduplicationWorker', worker_class)
- end
+ context 'with until_executing strategy' do
+ include_context 'server duplicate job', :until_executing
- around do |example|
- with_sidekiq_server_middleware do |chain|
- chain.add described_class
- Sidekiq::Testing.inline! { example.run }
+ describe '#call' do
+ it 'removes the stored job from redis before execution' do
+ bare_job = { 'class' => 'TestDeduplicationWorker', 'args' => ['hello'] }
+ job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'test_deduplication')
+
+ expect(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
+ .to receive(:new).with(a_hash_including(bare_job), 'test_deduplication')
+ .and_return(job_definition).twice # once in client middleware
+
+ expect(job_definition).to receive(:delete!).ordered.and_call_original
+ expect(TestDeduplicationWorker).to receive(:work).ordered.and_call_original
+
+ TestDeduplicationWorker.perform_async('hello')
+ end
end
end
- describe '#call' do
- it 'removes the stored job from redis' do
+ context 'with until_executed strategy' do
+ include_context 'server duplicate job', :until_executed
+
+ it 'removes the stored job from redis after execution' do
bare_job = { 'class' => 'TestDeduplicationWorker', 'args' => ['hello'] }
job_definition = Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob.new(bare_job.dup, 'test_deduplication')
expect(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
.to receive(:new).with(a_hash_including(bare_job), 'test_deduplication')
.and_return(job_definition).twice # once in client middleware
- expect(job_definition).to receive(:delete!).and_call_original
+
+ expect(TestDeduplicationWorker).to receive(:work).ordered.and_call_original
+ expect(job_definition).to receive(:delete!).ordered.and_call_original
TestDeduplicationWorker.perform_async('hello')
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb
new file mode 100644
index 00000000000..b3d463b6f6b
--- /dev/null
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executed_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecuted do
+ it_behaves_like 'deduplicating jobs when scheduling', :until_executed do
+ describe '#perform' do
+ let(:proc) { -> {} }
+
+ it 'deletes the lock after executing' do
+ expect(proc).to receive(:call).ordered
+ expect(fake_duplicate_job).to receive(:delete!).ordered
+
+ strategy.perform({}) do
+ proc.call
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
index 10b18052e9a..d45b6c5fcd1 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies/until_executing_spec.rb
@@ -1,146 +1,20 @@
# frozen_string_literal: true
-require 'fast_spec_helper'
+require 'spec_helper'
RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies::UntilExecuting do
- let(:fake_duplicate_job) do
- instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
- end
-
- subject(:strategy) { described_class.new(fake_duplicate_job) }
-
- describe '#schedule' do
- before do
- allow(Gitlab::SidekiqLogging::DeduplicationLogger.instance).to receive(:log)
- end
-
- it 'checks for duplicates before yielding' do
- expect(fake_duplicate_job).to receive(:scheduled?).twice.ordered.and_return(false)
- expect(fake_duplicate_job).to(
- receive(:check!)
- .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL)
- .ordered
- .and_return('a jid'))
- expect(fake_duplicate_job).to receive(:duplicate?).ordered.and_return(false)
-
- expect { |b| strategy.schedule({}, &b) }.to yield_control
- end
-
- it 'checks worker options for scheduled jobs' do
- expect(fake_duplicate_job).to receive(:scheduled?).ordered.and_return(true)
- expect(fake_duplicate_job).to receive(:options).ordered.and_return({})
- expect(fake_duplicate_job).not_to receive(:check!)
-
- expect { |b| strategy.schedule({}, &b) }.to yield_control
- end
-
- context 'job marking' do
- it 'adds the jid of the existing job to the job hash' do
- allow(fake_duplicate_job).to receive(:scheduled?).and_return(false)
- allow(fake_duplicate_job).to receive(:check!).and_return('the jid')
- allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
- allow(fake_duplicate_job).to receive(:options).and_return({})
- job_hash = {}
+ it_behaves_like 'deduplicating jobs when scheduling', :until_executing do
+ describe '#perform' do
+ let(:proc) { -> {} }
- expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
- expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
+ it 'deletes the lock before executing' do
+ expect(fake_duplicate_job).to receive(:delete!).ordered
+ expect(proc).to receive(:call).ordered
- strategy.schedule(job_hash) {}
-
- expect(job_hash).to include('duplicate-of' => 'the jid')
- end
-
- context 'scheduled jobs' do
- let(:time_diff) { 1.minute }
-
- context 'scheduled in the past' do
- it 'adds the jid of the existing job to the job hash' do
- allow(fake_duplicate_job).to receive(:scheduled?).twice.and_return(true)
- allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now - time_diff)
- allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true })
- allow(fake_duplicate_job).to(
- receive(:check!)
- .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL)
- .and_return('the jid'))
- allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
- job_hash = {}
-
- expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
- expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
-
- strategy.schedule(job_hash) {}
-
- expect(job_hash).to include('duplicate-of' => 'the jid')
- end
+ strategy.perform({}) do
+ proc.call
end
-
- context 'scheduled in the future' do
- it 'adds the jid of the existing job to the job hash' do
- freeze_time do
- allow(fake_duplicate_job).to receive(:scheduled?).twice.and_return(true)
- allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now + time_diff)
- allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true })
- allow(fake_duplicate_job).to(
- receive(:check!).with(time_diff.to_i).and_return('the jid'))
- allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
- job_hash = {}
-
- expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
- expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
-
- strategy.schedule(job_hash) {}
-
- expect(job_hash).to include('duplicate-of' => 'the jid')
- end
- end
- end
- end
- end
-
- context "when the job is droppable" do
- before do
- allow(fake_duplicate_job).to receive(:scheduled?).and_return(false)
- allow(fake_duplicate_job).to receive(:check!).and_return('the jid')
- allow(fake_duplicate_job).to receive(:duplicate?).and_return(true)
- allow(fake_duplicate_job).to receive(:options).and_return({})
- allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
- allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
- end
-
- it 'drops the job' do
- schedule_result = nil
-
- expect(fake_duplicate_job).to receive(:droppable?).and_return(true)
-
- expect { |b| schedule_result = strategy.schedule({}, &b) }.not_to yield_control
- expect(schedule_result).to be(false)
- end
-
- it 'logs that the job was dropped' do
- fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger)
-
- expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
- expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), 'dropped until executing', {})
-
- strategy.schedule({ 'jid' => 'new jid' }) {}
- end
-
- it 'logs the deduplication options of the worker' do
- fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger)
-
- expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
- allow(fake_duplicate_job).to receive(:options).and_return({ foo: :bar })
- expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), 'dropped until executing', { foo: :bar })
-
- strategy.schedule({ 'jid' => 'new jid' }) {}
end
end
end
-
- describe '#perform' do
- it 'deletes the lock before executing' do
- expect(fake_duplicate_job).to receive(:delete!).ordered
- expect { |b| strategy.perform({}, &b) }.to yield_control
- end
- end
end
diff --git a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
index 84856238aab..e35d779f334 100644
--- a/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
+++ b/spec/lib/gitlab/sidekiq_middleware/duplicate_jobs/strategies_spec.rb
@@ -8,6 +8,10 @@ RSpec.describe Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies do
expect(described_class.for(:until_executing)).to eq(described_class::UntilExecuting)
end
+ it 'returns the right class for `until_executed`' do
+ expect(described_class.for(:until_executed)).to eq(described_class::UntilExecuted)
+ end
+
it 'returns the right class for `none`' do
expect(described_class.for(:none)).to eq(described_class::None)
end
diff --git a/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
index 2f761b69e60..0b2055d3db5 100644
--- a/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
+++ b/spec/lib/gitlab/static_site_editor/config/generated_config_spec.rb
@@ -58,25 +58,9 @@ RSpec.describe Gitlab::StaticSiteEditor::Config::GeneratedConfig do
)
end
- context 'when feature flag is enabled' do
- let(:path) { 'FEATURE_ON.md.erb' }
+ let(:path) { 'README.md.erb' }
- before do
- stub_feature_flags(sse_erb_support: project)
- end
-
- it { is_expected.to include(is_supported_content: true) }
- end
-
- context 'when feature flag is disabled' do
- let(:path) { 'FEATURE_OFF.md.erb' }
-
- before do
- stub_feature_flags(sse_erb_support: false)
- end
-
- it { is_expected.to include(is_supported_content: false) }
- end
+ it { is_expected.to include(is_supported_content: true) }
end
context 'when file path is nested' do
diff --git a/spec/lib/gitlab/throttle_spec.rb b/spec/lib/gitlab/throttle_spec.rb
index ca2abe94ad2..7462b2e1c38 100644
--- a/spec/lib/gitlab/throttle_spec.rb
+++ b/spec/lib/gitlab/throttle_spec.rb
@@ -12,4 +12,22 @@ RSpec.describe Gitlab::Throttle do
subject
end
end
+
+ describe '.bypass_header' do
+ subject { described_class.bypass_header }
+
+ it 'is nil' do
+ expect(subject).to be_nil
+ end
+
+ context 'when a header is configured' do
+ before do
+ stub_env('GITLAB_THROTTLE_BYPASS_HEADER', 'My-Custom-Header')
+ end
+
+ it 'is a funny upper case rack key' do
+ expect(subject).to eq('HTTP_MY_CUSTOM_HEADER')
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb b/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb
new file mode 100644
index 00000000000..ee63eb6de04
--- /dev/null
+++ b/spec/lib/gitlab/tracking/destinations/snowplow_spec.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Tracking::Destinations::Snowplow do
+ let(:emitter) { SnowplowTracker::Emitter.new('localhost', buffer_size: 1) }
+ let(:tracker) { SnowplowTracker::Tracker.new(emitter, SnowplowTracker::Subject.new, 'namespace', 'app_id') }
+
+ before do
+ stub_application_setting(snowplow_collector_hostname: 'gitfoo.com')
+ stub_application_setting(snowplow_app_id: '_abc123_')
+ end
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ context 'when snowplow is enabled' do
+ before do
+ stub_application_setting(snowplow_enabled: true)
+
+ expect(SnowplowTracker::AsyncEmitter)
+ .to receive(:new)
+ .with('gitfoo.com', { protocol: 'https' })
+ .and_return(emitter)
+
+ expect(SnowplowTracker::Tracker)
+ .to receive(:new)
+ .with(emitter, an_instance_of(SnowplowTracker::Subject), Gitlab::Tracking::SNOWPLOW_NAMESPACE, '_abc123_')
+ .and_return(tracker)
+ end
+
+ describe '#event' do
+ it 'sends event to tracker' do
+ allow(tracker).to receive(:track_struct_event).and_call_original
+
+ subject.event('category', 'action', label: 'label', property: 'property', value: 1.5)
+
+ expect(tracker)
+ .to have_received(:track_struct_event)
+ .with('category', 'action', 'label', 'property', 1.5, nil, (Time.now.to_f * 1000).to_i)
+ end
+ end
+
+ describe '#self_describing_event' do
+ it 'sends event to tracker' do
+ allow(tracker).to receive(:track_self_describing_event).and_call_original
+
+ subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', foo: 'bar')
+
+ expect(tracker).to have_received(:track_self_describing_event) do |event, context, timestamp|
+ expect(event.to_json[:schema]).to eq('iglu:com.gitlab/foo/jsonschema/1-0-0')
+ expect(event.to_json[:data]).to eq(foo: 'bar')
+ expect(context).to eq(nil)
+ expect(timestamp).to eq((Time.now.to_f * 1000).to_i)
+ end
+ end
+ end
+ end
+
+ context 'when snowplow is not enabled' do
+ describe '#event' do
+ it 'does not send event to tracker' do
+ expect_any_instance_of(SnowplowTracker::Tracker).not_to receive(:track_struct_event)
+
+ subject.event('category', 'action', label: 'label', property: 'property', value: 1.5)
+ end
+ end
+
+ describe '#self_describing_event' do
+ it 'does not send event to tracker' do
+ expect_any_instance_of(SnowplowTracker::Tracker).not_to receive(:track_self_describing_event)
+
+ subject.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', foo: 'bar')
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/tracking/incident_management_spec.rb b/spec/lib/gitlab/tracking/incident_management_spec.rb
index 9c49c76ead7..fbcb9bf3e4c 100644
--- a/spec/lib/gitlab/tracking/incident_management_spec.rb
+++ b/spec/lib/gitlab/tracking/incident_management_spec.rb
@@ -62,7 +62,7 @@ RSpec.describe Gitlab::Tracking::IncidentManagement do
context 'param without label' do
let(:params) { { create_issue: '1' } }
- it_behaves_like 'a tracked event', "enabled_issue_auto_creation_on_alerts", {}
+ it_behaves_like 'a tracked event', "enabled_issue_auto_creation_on_alerts"
end
end
diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb
index 6ddeaf98370..805bd92fd43 100644
--- a/spec/lib/gitlab/tracking_spec.rb
+++ b/spec/lib/gitlab/tracking_spec.rb
@@ -2,13 +2,13 @@
require 'spec_helper'
RSpec.describe Gitlab::Tracking do
- let(:timestamp) { Time.utc(2017, 3, 22) }
-
before do
stub_application_setting(snowplow_enabled: true)
stub_application_setting(snowplow_collector_hostname: 'gitfoo.com')
stub_application_setting(snowplow_cookie_domain: '.gitfoo.com')
stub_application_setting(snowplow_app_id: '_abc123_')
+
+ described_class.instance_variable_set("@snowplow", nil)
end
describe '.snowplow_options' do
@@ -35,99 +35,23 @@ RSpec.describe Gitlab::Tracking do
end
end
- describe 'tracking events' do
- shared_examples 'events not tracked' do
- it 'does not track events' do
- stub_application_setting(snowplow_enabled: false)
- expect(SnowplowTracker::AsyncEmitter).not_to receive(:new)
- expect(SnowplowTracker::Tracker).not_to receive(:new)
-
- track_event
- end
- end
-
- around do |example|
- travel_to(timestamp) { example.run }
- end
-
- before do
- described_class.instance_variable_set("@snowplow", nil)
- end
-
- let(:tracker) { double }
-
- def receive_events
- expect(SnowplowTracker::AsyncEmitter).to receive(:new).with(
- 'gitfoo.com', { protocol: 'https' }
- ).and_return('_emitter_')
+ describe '.event' do
+ it 'delegates to snowplow destination' do
+ expect_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
+ .to receive(:event)
+ .with('category', 'action', label: 'label', property: 'property', value: 1.5, context: nil)
- expect(SnowplowTracker::Tracker).to receive(:new).with(
- '_emitter_',
- an_instance_of(SnowplowTracker::Subject),
- 'gl',
- '_abc123_'
- ).and_return(tracker)
+ described_class.event('category', 'action', label: 'label', property: 'property', value: 1.5)
end
+ end
- describe '.event' do
- let(:track_event) do
- described_class.event('category', 'action',
- label: '_label_',
- property: '_property_',
- value: '_value_',
- context: nil
- )
- end
-
- it_behaves_like 'events not tracked'
-
- it 'can track events' do
- receive_events
- expect(tracker).to receive(:track_struct_event).with(
- 'category',
- 'action',
- '_label_',
- '_property_',
- '_value_',
- nil,
- (timestamp.to_f * 1000).to_i
- )
-
- track_event
- end
- end
-
- describe '.self_describing_event' do
- let(:track_event) do
- described_class.self_describing_event('iglu:com.gitlab/example/jsonschema/1-0-2',
- {
- foo: 'bar',
- foo_count: 42
- },
- context: nil
- )
- end
-
- it_behaves_like 'events not tracked'
-
- it 'can track self describing events' do
- receive_events
- expect(SnowplowTracker::SelfDescribingJson).to receive(:new).with(
- 'iglu:com.gitlab/example/jsonschema/1-0-2',
- {
- foo: 'bar',
- foo_count: 42
- }
- ).and_return('_event_json_')
-
- expect(tracker).to receive(:track_self_describing_event).with(
- '_event_json_',
- nil,
- (timestamp.to_f * 1000).to_i
- )
+ describe '.self_describing_event' do
+ it 'delegates to snowplow destination' do
+ expect_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
+ .to receive(:self_describing_event)
+ .with('iglu:com.gitlab/foo/jsonschema/1-0-0', { foo: 'bar' }, context: nil)
- track_event
- end
+ described_class.self_describing_event('iglu:com.gitlab/foo/jsonschema/1-0-0', foo: 'bar')
end
end
end
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index b49efd6a092..f466d117851 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -350,7 +350,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
expect(described_class).to be_blocked_url('http://[fe80::c800:eff:fe74:8]', allow_local_network: false)
end
- context 'when local domain/IP is whitelisted' do
+ context 'when local domain/IP is allowed' do
let(:url_blocker_attributes) do
{
allow_localhost: false,
@@ -360,11 +360,11 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
before do
allow(ApplicationSetting).to receive(:current).and_return(ApplicationSetting.new)
- stub_application_setting(outbound_local_requests_whitelist: whitelist)
+ stub_application_setting(outbound_local_requests_whitelist: allowlist)
end
- context 'with IPs in whitelist' do
- let(:whitelist) do
+ context 'with IPs in allowlist' do
+ let(:allowlist) do
[
'0.0.0.0',
'127.0.0.1',
@@ -396,7 +396,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
it_behaves_like 'allows local requests', { allow_localhost: false, allow_local_network: false }
- it 'whitelists IP when dns_rebind_protection is disabled' do
+ it 'allows IP when dns_rebind_protection is disabled' do
url = "http://example.com"
attrs = url_blocker_attributes.merge(dns_rebind_protection: false)
@@ -410,8 +410,8 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
end
- context 'with domains in whitelist' do
- let(:whitelist) do
+ context 'with domains in allowlist' do
+ let(:allowlist) do
[
'www.example.com',
'example.com',
@@ -420,7 +420,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
]
end
- it 'allows domains present in whitelist' do
+ it 'allows domains present in allowlist' do
domain = 'example.com'
subdomain1 = 'www.example.com'
subdomain2 = 'subdomain.example.com'
@@ -435,7 +435,7 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
url_blocker_attributes)
end
- # subdomain2 is not part of the whitelist so it should be blocked
+ # subdomain2 is not part of the allowlist so it should be blocked
stub_domain_resolv(subdomain2, '192.168.1.1') do
expect(described_class).to be_blocked_url("http://#{subdomain2}",
url_blocker_attributes)
@@ -458,8 +458,8 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
shared_examples 'dns rebinding checks' do
- shared_examples 'whitelists the domain' do
- let(:whitelist) { [domain] }
+ shared_examples 'allowlists the domain' do
+ let(:allowlist) { [domain] }
let(:url) { "http://#{domain}" }
before do
@@ -475,13 +475,13 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
context 'enabled' do
let(:dns_rebind_value) { true }
- it_behaves_like 'whitelists the domain'
+ it_behaves_like 'allowlists the domain'
end
context 'disabled' do
let(:dns_rebind_value) { false }
- it_behaves_like 'whitelists the domain'
+ it_behaves_like 'allowlists the domain'
end
end
end
@@ -504,11 +504,11 @@ RSpec.describe Gitlab::UrlBlocker, :stub_invalid_dns_only do
end
context 'with ports' do
- let(:whitelist) do
+ let(:allowlist) do
["127.0.0.1:2000"]
end
- it 'allows domain with port when resolved ip has port whitelisted' do
+ it 'allows domain with port when resolved ip has port allowed' do
stub_domain_resolv("www.resolve-domain.com", '127.0.0.1') do
expect(described_class).not_to be_blocked_url("http://www.resolve-domain.com:2000", url_blocker_attributes)
end
diff --git a/spec/lib/gitlab/url_blockers/domain_allowlist_entry_spec.rb b/spec/lib/gitlab/url_blockers/domain_allowlist_entry_spec.rb
new file mode 100644
index 00000000000..ece0a018d53
--- /dev/null
+++ b/spec/lib/gitlab/url_blockers/domain_allowlist_entry_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UrlBlockers::DomainAllowlistEntry do
+ let(:domain) { 'www.example.com' }
+
+ describe '#initialize' do
+ it 'initializes without port' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry.domain).to eq(domain)
+ expect(domain_allowlist_entry.port).to be(nil)
+ end
+
+ it 'initializes with port' do
+ port = 8080
+ domain_allowlist_entry = described_class.new(domain, port: port)
+
+ expect(domain_allowlist_entry.domain).to eq(domain)
+ expect(domain_allowlist_entry.port).to eq(port)
+ end
+ end
+
+ describe '#match?' do
+ it 'matches when domain and port are equal' do
+ port = 8080
+ domain_allowlist_entry = described_class.new(domain, port: port)
+
+ expect(domain_allowlist_entry).to be_match(domain, port)
+ end
+
+ it 'matches any port when port is nil' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).to be_match(domain, 8080)
+ expect(domain_allowlist_entry).to be_match(domain, 9090)
+ end
+
+ it 'does not match when port is present but requested_port is nil' do
+ domain_allowlist_entry = described_class.new(domain, port: 8080)
+
+ expect(domain_allowlist_entry).not_to be_match(domain, nil)
+ end
+
+ it 'matches when port and requested_port are nil' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).to be_match(domain)
+ end
+
+ it 'does not match if domain is not equal' do
+ domain_allowlist_entry = described_class.new(domain)
+
+ expect(domain_allowlist_entry).not_to be_match('www.gitlab.com', 8080)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/url_blockers/domain_whitelist_entry_spec.rb b/spec/lib/gitlab/url_blockers/domain_whitelist_entry_spec.rb
deleted file mode 100644
index 58bae109146..00000000000
--- a/spec/lib/gitlab/url_blockers/domain_whitelist_entry_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::UrlBlockers::DomainWhitelistEntry do
- let(:domain) { 'www.example.com' }
-
- describe '#initialize' do
- it 'initializes without port' do
- domain_whitelist_entry = described_class.new(domain)
-
- expect(domain_whitelist_entry.domain).to eq(domain)
- expect(domain_whitelist_entry.port).to be(nil)
- end
-
- it 'initializes with port' do
- port = 8080
- domain_whitelist_entry = described_class.new(domain, port: port)
-
- expect(domain_whitelist_entry.domain).to eq(domain)
- expect(domain_whitelist_entry.port).to eq(port)
- end
- end
-
- describe '#match?' do
- it 'matches when domain and port are equal' do
- port = 8080
- domain_whitelist_entry = described_class.new(domain, port: port)
-
- expect(domain_whitelist_entry).to be_match(domain, port)
- end
-
- it 'matches any port when port is nil' do
- domain_whitelist_entry = described_class.new(domain)
-
- expect(domain_whitelist_entry).to be_match(domain, 8080)
- expect(domain_whitelist_entry).to be_match(domain, 9090)
- end
-
- it 'does not match when port is present but requested_port is nil' do
- domain_whitelist_entry = described_class.new(domain, port: 8080)
-
- expect(domain_whitelist_entry).not_to be_match(domain, nil)
- end
-
- it 'matches when port and requested_port are nil' do
- domain_whitelist_entry = described_class.new(domain)
-
- expect(domain_whitelist_entry).to be_match(domain)
- end
-
- it 'does not match if domain is not equal' do
- domain_whitelist_entry = described_class.new(domain)
-
- expect(domain_whitelist_entry).not_to be_match('www.gitlab.com', 8080)
- end
- end
-end
diff --git a/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb b/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb
new file mode 100644
index 00000000000..110a6c17adb
--- /dev/null
+++ b/spec/lib/gitlab/url_blockers/ip_allowlist_entry_spec.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UrlBlockers::IpAllowlistEntry do
+ let(:ipv4) { IPAddr.new('192.168.1.1') }
+
+ describe '#initialize' do
+ it 'initializes without port' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry.ip).to eq(ipv4)
+ expect(ip_allowlist_entry.port).to be(nil)
+ end
+
+ it 'initializes with port' do
+ port = 8080
+ ip_allowlist_entry = described_class.new(ipv4, port: port)
+
+ expect(ip_allowlist_entry.ip).to eq(ipv4)
+ expect(ip_allowlist_entry.port).to eq(port)
+ end
+ end
+
+ describe '#match?' do
+ it 'matches with equivalent IP and port' do
+ port = 8080
+ ip_allowlist_entry = described_class.new(ipv4, port: port)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, port)
+ end
+
+ it 'matches any port when port is nil' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, 8080)
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s, 9090)
+ end
+
+ it 'does not match when port is present but requested_port is nil' do
+ ip_allowlist_entry = described_class.new(ipv4, port: 8080)
+
+ expect(ip_allowlist_entry).not_to be_match(ipv4.to_s, nil)
+ end
+
+ it 'matches when port and requested_port are nil' do
+ ip_allowlist_entry = described_class.new(ipv4)
+
+ expect(ip_allowlist_entry).to be_match(ipv4.to_s)
+ end
+
+ it 'works with ipv6' do
+ ipv6 = IPAddr.new('fe80::c800:eff:fe74:8')
+ ip_allowlist_entry = described_class.new(ipv6)
+
+ expect(ip_allowlist_entry).to be_match(ipv6.to_s, 8080)
+ end
+
+ it 'matches ipv4 within IPv4 range' do
+ ipv4_range = IPAddr.new('127.0.0.0/28')
+ ip_allowlist_entry = described_class.new(ipv4_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv4_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('127.0.1.1', 8080)
+ end
+
+ it 'matches IPv6 within IPv6 range' do
+ ipv6_range = IPAddr.new('fd84:6d02:f6d8:c89e::/124')
+ ip_allowlist_entry = described_class.new(ipv6_range)
+
+ expect(ip_allowlist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
+ expect(ip_allowlist_entry).not_to be_match('fd84:6d02:f6d8:f::f', 8080)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/url_blockers/ip_whitelist_entry_spec.rb b/spec/lib/gitlab/url_blockers/ip_whitelist_entry_spec.rb
deleted file mode 100644
index 52f9b31165a..00000000000
--- a/spec/lib/gitlab/url_blockers/ip_whitelist_entry_spec.rb
+++ /dev/null
@@ -1,75 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::UrlBlockers::IpWhitelistEntry do
- let(:ipv4) { IPAddr.new('192.168.1.1') }
-
- describe '#initialize' do
- it 'initializes without port' do
- ip_whitelist_entry = described_class.new(ipv4)
-
- expect(ip_whitelist_entry.ip).to eq(ipv4)
- expect(ip_whitelist_entry.port).to be(nil)
- end
-
- it 'initializes with port' do
- port = 8080
- ip_whitelist_entry = described_class.new(ipv4, port: port)
-
- expect(ip_whitelist_entry.ip).to eq(ipv4)
- expect(ip_whitelist_entry.port).to eq(port)
- end
- end
-
- describe '#match?' do
- it 'matches with equivalent IP and port' do
- port = 8080
- ip_whitelist_entry = described_class.new(ipv4, port: port)
-
- expect(ip_whitelist_entry).to be_match(ipv4.to_s, port)
- end
-
- it 'matches any port when port is nil' do
- ip_whitelist_entry = described_class.new(ipv4)
-
- expect(ip_whitelist_entry).to be_match(ipv4.to_s, 8080)
- expect(ip_whitelist_entry).to be_match(ipv4.to_s, 9090)
- end
-
- it 'does not match when port is present but requested_port is nil' do
- ip_whitelist_entry = described_class.new(ipv4, port: 8080)
-
- expect(ip_whitelist_entry).not_to be_match(ipv4.to_s, nil)
- end
-
- it 'matches when port and requested_port are nil' do
- ip_whitelist_entry = described_class.new(ipv4)
-
- expect(ip_whitelist_entry).to be_match(ipv4.to_s)
- end
-
- it 'works with ipv6' do
- ipv6 = IPAddr.new('fe80::c800:eff:fe74:8')
- ip_whitelist_entry = described_class.new(ipv6)
-
- expect(ip_whitelist_entry).to be_match(ipv6.to_s, 8080)
- end
-
- it 'matches ipv4 within IPv4 range' do
- ipv4_range = IPAddr.new('127.0.0.0/28')
- ip_whitelist_entry = described_class.new(ipv4_range)
-
- expect(ip_whitelist_entry).to be_match(ipv4_range.to_range.last.to_s, 8080)
- expect(ip_whitelist_entry).not_to be_match('127.0.1.1', 8080)
- end
-
- it 'matches IPv6 within IPv6 range' do
- ipv6_range = IPAddr.new('fd84:6d02:f6d8:c89e::/124')
- ip_whitelist_entry = described_class.new(ipv6_range)
-
- expect(ip_whitelist_entry).to be_match(ipv6_range.to_range.last.to_s, 8080)
- expect(ip_whitelist_entry).not_to be_match('fd84:6d02:f6d8:f::f', 8080)
- end
- end
-end
diff --git a/spec/lib/gitlab/url_blockers/url_allowlist_spec.rb b/spec/lib/gitlab/url_blockers/url_allowlist_spec.rb
new file mode 100644
index 00000000000..d9e44e9b85c
--- /dev/null
+++ b/spec/lib/gitlab/url_blockers/url_allowlist_spec.rb
@@ -0,0 +1,164 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::UrlBlockers::UrlAllowlist do
+ include StubRequests
+
+ let(:allowlist) { [] }
+
+ before do
+ allow(ApplicationSetting).to receive(:current).and_return(ApplicationSetting.new)
+ stub_application_setting(outbound_local_requests_whitelist: allowlist)
+ end
+
+ describe '#domain_allowed?' do
+ let(:allowlist) { %w[www.example.com example.com] }
+
+ it 'returns true if domains present in allowlist' do
+ not_allowed = %w[subdomain.example.com example.org]
+
+ aggregate_failures do
+ allowlist.each do |domain|
+ expect(described_class).to be_domain_allowed(domain)
+ end
+
+ not_allowed.each do |domain|
+ expect(described_class).not_to be_domain_allowed(domain)
+ end
+ end
+ end
+
+ it 'returns false when domain is blank' do
+ expect(described_class).not_to be_domain_allowed(nil)
+ end
+
+ context 'with ports' do
+ let(:allowlist) { ['example.io:3000'] }
+
+ it 'returns true if domain and ports present in allowlist' do
+ parsed_allowlist = [['example.io', { port: 3000 }]]
+ not_allowed = [
+ 'example.io',
+ ['example.io', { port: 3001 }]
+ ]
+
+ aggregate_failures do
+ parsed_allowlist.each do |domain_and_port|
+ expect(described_class).to be_domain_allowed(*domain_and_port)
+ end
+
+ not_allowed.each do |domain_and_port|
+ expect(described_class).not_to be_domain_allowed(*domain_and_port)
+ end
+ end
+ end
+ end
+ end
+
+ describe '#ip_allowed?' do
+ let(:allowlist) do
+ [
+ '0.0.0.0',
+ '127.0.0.1',
+ '192.168.1.1',
+ '0:0:0:0:0:ffff:192.168.1.2',
+ '::ffff:c0a8:102',
+ 'fc00:bf8b:e62c:abcd:abcd:aaaa:aaaa:aaaa',
+ '0:0:0:0:0:ffff:169.254.169.254',
+ '::ffff:a9fe:a9fe',
+ '::ffff:a9fe:a864',
+ 'fe80::c800:eff:fe74:8'
+ ]
+ end
+
+ it 'returns true if ips present in allowlist' do
+ aggregate_failures do
+ allowlist.each do |ip_address|
+ expect(described_class).to be_ip_allowed(ip_address)
+ end
+
+ %w[172.16.2.2 127.0.0.2 fe80::c800:eff:fe74:9].each do |ip_address|
+ expect(described_class).not_to be_ip_allowed(ip_address)
+ end
+ end
+ end
+
+ it 'returns false when ip is blank' do
+ expect(described_class).not_to be_ip_allowed(nil)
+ end
+
+ context 'with ip ranges in allowlist' do
+ let(:ipv4_range) { '127.0.0.0/28' }
+ let(:ipv6_range) { 'fd84:6d02:f6d8:c89e::/124' }
+
+ let(:allowlist) do
+ [
+ ipv4_range,
+ ipv6_range
+ ]
+ end
+
+ it 'does not allowlist ipv4 range when not in allowlist' do
+ stub_application_setting(outbound_local_requests_whitelist: [])
+
+ IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
+ expect(described_class).not_to be_ip_allowed(ip.to_s)
+ end
+ end
+
+ it 'allowlists all ipv4s in the range when in allowlist' do
+ IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
+ expect(described_class).to be_ip_allowed(ip.to_s)
+ end
+ end
+
+ it 'does not allowlist ipv6 range when not in allowlist' do
+ stub_application_setting(outbound_local_requests_whitelist: [])
+
+ IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
+ expect(described_class).not_to be_ip_allowed(ip.to_s)
+ end
+ end
+
+ it 'allowlists all ipv6s in the range when in allowlist' do
+ IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
+ expect(described_class).to be_ip_allowed(ip.to_s)
+ end
+ end
+
+ it 'does not allowlist IPs outside the range' do
+ expect(described_class).not_to be_ip_allowed("fd84:6d02:f6d8:c89e:0:0:1:f")
+
+ expect(described_class).not_to be_ip_allowed("127.0.1.15")
+ end
+ end
+
+ context 'with ports' do
+ let(:allowlist) { %w[127.0.0.9:3000 [2001:db8:85a3:8d3:1319:8a2e:370:7348]:443] }
+
+ it 'returns true if ip and ports present in allowlist' do
+ parsed_allowlist = [
+ ['127.0.0.9', { port: 3000 }],
+ ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', { port: 443 }]
+ ]
+ not_allowed = [
+ '127.0.0.9',
+ ['127.0.0.9', { port: 3001 }],
+ '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
+ ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', { port: 3001 }]
+ ]
+
+ aggregate_failures do
+ parsed_allowlist.each do |ip_and_port|
+ expect(described_class).to be_ip_allowed(*ip_and_port)
+ end
+
+ not_allowed.each do |ip_and_port|
+ expect(described_class).not_to be_ip_allowed(*ip_and_port)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/url_blockers/url_whitelist_spec.rb b/spec/lib/gitlab/url_blockers/url_whitelist_spec.rb
deleted file mode 100644
index 7a65516be3c..00000000000
--- a/spec/lib/gitlab/url_blockers/url_whitelist_spec.rb
+++ /dev/null
@@ -1,164 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::UrlBlockers::UrlWhitelist do
- include StubRequests
-
- let(:whitelist) { [] }
-
- before do
- allow(ApplicationSetting).to receive(:current).and_return(ApplicationSetting.new)
- stub_application_setting(outbound_local_requests_whitelist: whitelist)
- end
-
- describe '#domain_whitelisted?' do
- let(:whitelist) { ['www.example.com', 'example.com'] }
-
- it 'returns true if domains present in whitelist' do
- not_whitelisted = ['subdomain.example.com', 'example.org']
-
- aggregate_failures do
- whitelist.each do |domain|
- expect(described_class).to be_domain_whitelisted(domain)
- end
-
- not_whitelisted.each do |domain|
- expect(described_class).not_to be_domain_whitelisted(domain)
- end
- end
- end
-
- it 'returns false when domain is blank' do
- expect(described_class).not_to be_domain_whitelisted(nil)
- end
-
- context 'with ports' do
- let(:whitelist) { ['example.io:3000'] }
-
- it 'returns true if domain and ports present in whitelist' do
- parsed_whitelist = [['example.io', { port: 3000 }]]
- not_whitelisted = [
- 'example.io',
- ['example.io', { port: 3001 }]
- ]
-
- aggregate_failures do
- parsed_whitelist.each do |domain_and_port|
- expect(described_class).to be_domain_whitelisted(*domain_and_port)
- end
-
- not_whitelisted.each do |domain_and_port|
- expect(described_class).not_to be_domain_whitelisted(*domain_and_port)
- end
- end
- end
- end
- end
-
- describe '#ip_whitelisted?' do
- let(:whitelist) do
- [
- '0.0.0.0',
- '127.0.0.1',
- '192.168.1.1',
- '0:0:0:0:0:ffff:192.168.1.2',
- '::ffff:c0a8:102',
- 'fc00:bf8b:e62c:abcd:abcd:aaaa:aaaa:aaaa',
- '0:0:0:0:0:ffff:169.254.169.254',
- '::ffff:a9fe:a9fe',
- '::ffff:a9fe:a864',
- 'fe80::c800:eff:fe74:8'
- ]
- end
-
- it 'returns true if ips present in whitelist' do
- aggregate_failures do
- whitelist.each do |ip_address|
- expect(described_class).to be_ip_whitelisted(ip_address)
- end
-
- ['172.16.2.2', '127.0.0.2', 'fe80::c800:eff:fe74:9'].each do |ip_address|
- expect(described_class).not_to be_ip_whitelisted(ip_address)
- end
- end
- end
-
- it 'returns false when ip is blank' do
- expect(described_class).not_to be_ip_whitelisted(nil)
- end
-
- context 'with ip ranges in whitelist' do
- let(:ipv4_range) { '127.0.0.0/28' }
- let(:ipv6_range) { 'fd84:6d02:f6d8:c89e::/124' }
-
- let(:whitelist) do
- [
- ipv4_range,
- ipv6_range
- ]
- end
-
- it 'does not whitelist ipv4 range when not in whitelist' do
- stub_application_setting(outbound_local_requests_whitelist: [])
-
- IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
- expect(described_class).not_to be_ip_whitelisted(ip.to_s)
- end
- end
-
- it 'whitelists all ipv4s in the range when in whitelist' do
- IPAddr.new(ipv4_range).to_range.to_a.each do |ip|
- expect(described_class).to be_ip_whitelisted(ip.to_s)
- end
- end
-
- it 'does not whitelist ipv6 range when not in whitelist' do
- stub_application_setting(outbound_local_requests_whitelist: [])
-
- IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
- expect(described_class).not_to be_ip_whitelisted(ip.to_s)
- end
- end
-
- it 'whitelists all ipv6s in the range when in whitelist' do
- IPAddr.new(ipv6_range).to_range.to_a.each do |ip|
- expect(described_class).to be_ip_whitelisted(ip.to_s)
- end
- end
-
- it 'does not whitelist IPs outside the range' do
- expect(described_class).not_to be_ip_whitelisted("fd84:6d02:f6d8:c89e:0:0:1:f")
-
- expect(described_class).not_to be_ip_whitelisted("127.0.1.15")
- end
- end
-
- context 'with ports' do
- let(:whitelist) { ['127.0.0.9:3000', '[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443'] }
-
- it 'returns true if ip and ports present in whitelist' do
- parsed_whitelist = [
- ['127.0.0.9', { port: 3000 }],
- ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', { port: 443 }]
- ]
- not_whitelisted = [
- '127.0.0.9',
- ['127.0.0.9', { port: 3001 }],
- '[2001:db8:85a3:8d3:1319:8a2e:370:7348]',
- ['[2001:db8:85a3:8d3:1319:8a2e:370:7348]', { port: 3001 }]
- ]
-
- aggregate_failures do
- parsed_whitelist.each do |ip_and_port|
- expect(described_class).to be_ip_whitelisted(*ip_and_port)
- end
-
- not_whitelisted.each do |ip_and_port|
- expect(described_class).not_to be_ip_whitelisted(*ip_and_port)
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/url_builder_spec.rb b/spec/lib/gitlab/url_builder_spec.rb
index b58b5a84662..c892f1f0410 100644
--- a/spec/lib/gitlab/url_builder_spec.rb
+++ b/spec/lib/gitlab/url_builder_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe Gitlab::UrlBuilder do
:project_milestone | ->(milestone) { "/#{milestone.project.full_path}/-/milestones/#{milestone.iid}" }
:project_snippet | ->(snippet) { "/#{snippet.project.full_path}/-/snippets/#{snippet.id}" }
:project_wiki | ->(wiki) { "/#{wiki.container.full_path}/-/wikis/home" }
+ :release | ->(release) { "/#{release.project.full_path}/-/releases/#{release.tag}" }
:ci_build | ->(build) { "/#{build.project.full_path}/-/jobs/#{build.id}" }
:design | ->(design) { "/#{design.project.full_path}/-/design_management/designs/#{design.id}/raw_image" }
diff --git a/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb
new file mode 100644
index 00000000000..e9fb5346eae
--- /dev/null
+++ b/spec/lib/gitlab/usage_data_counters/aggregated_metrics_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'aggregated metrics' do
+ RSpec::Matchers.define :be_known_event do
+ match do |event|
+ Gitlab::UsageDataCounters::HLLRedisCounter.known_event?(event)
+ end
+
+ failure_message do
+ "Event with name: `#{event}` can not be found within `#{Gitlab::UsageDataCounters::HLLRedisCounter::KNOWN_EVENTS_PATH}`"
+ end
+ end
+
+ let_it_be(:known_events) do
+ Gitlab::UsageDataCounters::HLLRedisCounter.known_events
+ end
+
+ Gitlab::UsageDataCounters::HLLRedisCounter.aggregated_metrics.tap do |aggregated_metrics|
+ it 'all events has unique name' do
+ event_names = aggregated_metrics&.map { |event| event[:name] }
+
+ expect(event_names).to eq(event_names&.uniq)
+ end
+
+ aggregated_metrics&.each do |aggregate|
+ context "for #{aggregate[:name]} aggregate of #{aggregate[:events].join(' ')}" do
+ let_it_be(:events_records) { known_events.select { |event| aggregate[:events].include?(event[:name]) } }
+
+ it "only refers to known events" do
+ expect(aggregate[:events]).to all be_known_event
+ end
+
+ it "has expected structure" do
+ expect(aggregate.keys).to include(*%w[name operator events])
+ end
+
+ it "uses allowed aggregation operators" do
+ expect(Gitlab::UsageDataCounters::HLLRedisCounter::ALLOWED_METRICS_AGGREGATIONS).to include aggregate[:operator]
+ end
+
+ it "uses events from the same Redis slot" do
+ event_slots = events_records.map { |event| event[:redis_slot] }.uniq
+
+ expect(event_slots).to contain_exactly(be_present)
+ end
+
+ it "uses events with the same aggregation period" do
+ event_slots = events_records.map { |event| event[:aggregation] }.uniq
+
+ expect(event_slots).to contain_exactly(be_present)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
index e84c3c17274..93704a39555 100644
--- a/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/hll_redis_counter_spec.rb
@@ -8,6 +8,9 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:entity3) { '34rfjuuy-ce56-sa35-ds34-dfer567dfrf2' }
let(:entity4) { '8b9a2671-2abf-4bec-a682-22f6a8f7bf31' }
+ let(:default_context) { 'default' }
+ let(:invalid_context) { 'invalid' }
+
around do |example|
# We need to freeze to a reference time
# because visits are grouped by the week number in the year
@@ -20,7 +23,28 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
describe '.categories' do
it 'gets all unique category names' do
- expect(described_class.categories).to contain_exactly('analytics', 'compliance', 'ide_edit', 'search', 'source_code', 'incident_management', 'issues_edit', 'testing')
+ expect(described_class.categories).to contain_exactly(
+ 'compliance',
+ 'analytics',
+ 'ide_edit',
+ 'search',
+ 'source_code',
+ 'incident_management',
+ 'testing',
+ 'issues_edit',
+ 'ci_secrets_management',
+ 'maven_packages',
+ 'npm_packages',
+ 'conan_packages',
+ 'nuget_packages',
+ 'pypi_packages',
+ 'composer_packages',
+ 'generic_packages',
+ 'golang_packages',
+ 'debian_packages',
+ 'container_packages',
+ 'tag_packages'
+ )
end
end
@@ -34,11 +58,13 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
let(:no_slot) { 'no_slot' }
let(:different_aggregation) { 'different_aggregation' }
let(:custom_daily_event) { 'g_analytics_custom' }
+ let(:context_event) { 'context_event' }
let(:global_category) { 'global' }
- let(:compliance_category) {'compliance' }
- let(:productivity_category) {'productivity' }
+ let(:compliance_category) { 'compliance' }
+ let(:productivity_category) { 'productivity' }
let(:analytics_category) { 'analytics' }
+ let(:other_category) { 'other' }
let(:known_events) do
[
@@ -47,7 +73,8 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
{ name: category_productivity_event, redis_slot: "analytics", category: productivity_category, aggregation: "weekly" },
{ name: compliance_slot_event, redis_slot: "compliance", category: compliance_category, aggregation: "weekly" },
{ name: no_slot, category: global_category, aggregation: "daily" },
- { name: different_aggregation, category: global_category, aggregation: "monthly" }
+ { name: different_aggregation, category: global_category, aggregation: "monthly" },
+ { name: context_event, category: other_category, expiry: 6, aggregation: 'weekly' }
].map(&:with_indifferent_access)
end
@@ -77,12 +104,18 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
stub_application_setting(usage_ping_enabled: true)
end
+ it 'tracks event when using symbol' do
+ expect(Gitlab::Redis::HLL).to receive(:add)
+
+ described_class.track_event(entity1, :g_analytics_contribution)
+ end
+
it "raise error if metrics don't have same aggregation" do
- expect { described_class.track_event(entity1, different_aggregation, Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation)
+ expect { described_class.track_event(entity1, different_aggregation, Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownAggregation)
end
it 'raise error if metrics of unknown aggregation' do
- expect { described_class.track_event(entity1, 'unknown', Date.current) } .to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
+ expect { described_class.track_event(entity1, 'unknown', Date.current) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::UnknownEvent)
end
context 'for weekly events' do
@@ -143,6 +176,34 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
end
+ describe '.track_event_in_context' do
+ context 'with valid contex' do
+ it 'increments conext event counte' do
+ expect(Gitlab::Redis::HLL).to receive(:add) do |kwargs|
+ expect(kwargs[:key]).to match(/^#{default_context}\_.*/)
+ end
+
+ described_class.track_event_in_context(entity1, context_event, default_context)
+ end
+ end
+
+ context 'with empty context' do
+ it 'does not increment a counter' do
+ expect(Gitlab::Redis::HLL).not_to receive(:add)
+
+ described_class.track_event_in_context(entity1, context_event, '')
+ end
+ end
+
+ context 'when sending invalid context' do
+ it 'does not increment a counter' do
+ expect(Gitlab::Redis::HLL).not_to receive(:add)
+
+ described_class.track_event_in_context(entity1, context_event, invalid_context)
+ end
+ end
+ end
+
describe '.unique_events' do
before do
# events in current week, should not be counted as week is not complete
@@ -178,37 +239,89 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
end
it 'raise error if metrics are not in the same slot' do
- expect { described_class.unique_events(event_names: [compliance_slot_event, analytics_slot_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same slot')
+ expect do
+ described_class.unique_events(event_names: [compliance_slot_event, analytics_slot_event], start_date: 4.weeks.ago, end_date: Date.current)
+ end.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::SlotMismatch)
end
it 'raise error if metrics are not in the same category' do
- expect { described_class.unique_events(event_names: [category_analytics_event, category_productivity_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should be in same category')
+ expect do
+ described_class.unique_events(event_names: [category_analytics_event, category_productivity_event], start_date: 4.weeks.ago, end_date: Date.current)
+ end.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::CategoryMismatch)
end
it "raise error if metrics don't have same aggregation" do
- expect { described_class.unique_events(event_names: [daily_event, weekly_event], start_date: 4.weeks.ago, end_date: Date.current) }.to raise_error('Events should have same aggregation level')
+ expect do
+ described_class.unique_events(event_names: [daily_event, weekly_event], start_date: 4.weeks.ago, end_date: Date.current)
+ end.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::AggregationMismatch)
end
context 'when data for the last complete week' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 1.week.ago, end_date: Date.current)).to eq(1) }
+ it { expect(described_class.unique_events(event_names: [weekly_event], start_date: 1.week.ago, end_date: Date.current)).to eq(1) }
end
context 'when data for the last 4 complete weeks' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: Date.current)).to eq(2) }
+ it { expect(described_class.unique_events(event_names: [weekly_event], start_date: 4.weeks.ago, end_date: Date.current)).to eq(2) }
end
context 'when data for the week 4 weeks ago' do
- it { expect(described_class.unique_events(event_names: weekly_event, start_date: 4.weeks.ago, end_date: 3.weeks.ago)).to eq(1) }
+ it { expect(described_class.unique_events(event_names: [weekly_event], start_date: 4.weeks.ago, end_date: 3.weeks.ago)).to eq(1) }
+ end
+
+ context 'when using symbol as parameter' do
+ it { expect(described_class.unique_events(event_names: [weekly_event.to_sym], start_date: 4.weeks.ago, end_date: 3.weeks.ago)).to eq(1) }
end
context 'when using daily aggregation' do
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 7.days.ago, end_date: Date.current)).to eq(2) }
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: Date.current)).to eq(3) }
- it { expect(described_class.unique_events(event_names: daily_event, start_date: 28.days.ago, end_date: 21.days.ago)).to eq(1) }
+ it { expect(described_class.unique_events(event_names: [daily_event], start_date: 7.days.ago, end_date: Date.current)).to eq(2) }
+ it { expect(described_class.unique_events(event_names: [daily_event], start_date: 28.days.ago, end_date: Date.current)).to eq(3) }
+ it { expect(described_class.unique_events(event_names: [daily_event], start_date: 28.days.ago, end_date: 21.days.ago)).to eq(1) }
end
context 'when no slot is set' do
- it { expect(described_class.unique_events(event_names: no_slot, start_date: 7.days.ago, end_date: Date.current)).to eq(1) }
+ it { expect(described_class.unique_events(event_names: [no_slot], start_date: 7.days.ago, end_date: Date.current)).to eq(1) }
+ end
+ end
+ end
+
+ describe 'context level tracking' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:known_events) do
+ [
+ { name: 'event_name_1', redis_slot: 'event', category: 'category1', aggregation: "weekly" },
+ { name: 'event_name_2', redis_slot: 'event', category: 'category1', aggregation: "weekly" },
+ { name: 'event_name_3', redis_slot: 'event', category: 'category1', aggregation: "weekly" }
+ ].map(&:with_indifferent_access)
+ end
+
+ before do
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ allow(described_class).to receive(:categories).and_return(%w(category1 category2))
+
+ described_class.track_event_in_context([entity1, entity3], 'event_name_1', default_context, 2.days.ago)
+ described_class.track_event_in_context(entity3, 'event_name_1', default_context, 2.days.ago)
+ described_class.track_event_in_context(entity3, 'event_name_1', invalid_context, 2.days.ago)
+ described_class.track_event_in_context([entity1, entity2], 'event_name_2', '', 2.weeks.ago)
+ end
+
+ subject(:unique_events) { described_class.unique_events(event_names: event_names, start_date: 4.weeks.ago, end_date: Date.current, context: context) }
+
+ context 'with correct arguments' do
+ where(:event_names, :context, :value) do
+ ['event_name_1'] | 'default' | 2
+ ['event_name_1'] | '' | 0
+ ['event_name_2'] | '' | 0
+ end
+
+ with_them do
+ it { is_expected.to eq value }
+ end
+ end
+
+ context 'with invalid context' do
+ it 'raise error' do
+ expect { described_class.unique_events(event_names: 'event_name_1', start_date: 4.weeks.ago, end_date: Date.current, context: invalid_context) }.to raise_error(Gitlab::UsageDataCounters::HLLRedisCounter::InvalidContext)
end
end
end
@@ -257,4 +370,183 @@ RSpec.describe Gitlab::UsageDataCounters::HLLRedisCounter, :clean_gitlab_redis_s
expect(subject.unique_events_data).to eq(results)
end
end
+
+ context 'aggregated_metrics_data' do
+ let(:known_events) do
+ [
+ { name: 'event1_slot', redis_slot: "slot", category: 'category1', aggregation: "weekly" },
+ { name: 'event2_slot', redis_slot: "slot", category: 'category2', aggregation: "weekly" },
+ { name: 'event3_slot', redis_slot: "slot", category: 'category3', aggregation: "weekly" },
+ { name: 'event5_slot', redis_slot: "slot", category: 'category4', aggregation: "weekly" },
+ { name: 'event4', category: 'category2', aggregation: "weekly" }
+ ].map(&:with_indifferent_access)
+ end
+
+ before do
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ end
+
+ shared_examples 'aggregated_metrics_data' do
+ context 'no aggregated metrics is defined' do
+ it 'returns empty hash' do
+ allow(described_class).to receive(:aggregated_metrics).and_return([])
+
+ expect(aggregated_metrics_data).to eq({})
+ end
+ end
+
+ context 'there are aggregated metrics defined' do
+ before do
+ allow(described_class).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+ end
+
+ context 'with AND operator' do
+ let(:aggregated_metrics) do
+ [
+ { name: 'gmau_1', events: %w[event1_slot event2_slot], operator: "AND" },
+ { name: 'gmau_2', events: %w[event1_slot event2_slot event3_slot], operator: "AND" },
+ { name: 'gmau_3', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "AND" },
+ { name: 'gmau_4', events: %w[event4], operator: "AND" }
+ ].map(&:with_indifferent_access)
+ end
+
+ it 'returns the number of unique events for all known events' do
+ results = {
+ 'gmau_1' => 3,
+ 'gmau_2' => 2,
+ 'gmau_3' => 1,
+ 'gmau_4' => 3
+ }
+
+ expect(aggregated_metrics_data).to eq(results)
+ end
+ end
+
+ context 'with OR operator' do
+ let(:aggregated_metrics) do
+ [
+ { name: 'gmau_1', events: %w[event3_slot event5_slot], operator: "OR" },
+ { name: 'gmau_2', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "OR" },
+ { name: 'gmau_3', events: %w[event4], operator: "OR" }
+ ].map(&:with_indifferent_access)
+ end
+
+ it 'returns the number of unique events for all known events' do
+ results = {
+ 'gmau_1' => 2,
+ 'gmau_2' => 3,
+ 'gmau_3' => 3
+ }
+
+ expect(aggregated_metrics_data).to eq(results)
+ end
+ end
+
+ context 'hidden behind feature flag' do
+ let(:enabled_feature_flag) { 'test_ff_enabled' }
+ let(:disabled_feature_flag) { 'test_ff_disabled' }
+ let(:aggregated_metrics) do
+ [
+ # represents stable aggregated metrics that has been fully released
+ { name: 'gmau_without_ff', events: %w[event3_slot event5_slot], operator: "OR" },
+ # represents new aggregated metric that is under performance testing on gitlab.com
+ { name: 'gmau_enabled', events: %w[event4], operator: "AND", feature_flag: enabled_feature_flag },
+ # represents aggregated metric that is under development and shouldn't be yet collected even on gitlab.com
+ { name: 'gmau_disabled', events: %w[event4], operator: "AND", feature_flag: disabled_feature_flag }
+ ].map(&:with_indifferent_access)
+ end
+
+ it 'returns the number of unique events for all known events' do
+ skip_feature_flags_yaml_validation
+ stub_feature_flags(enabled_feature_flag => true, disabled_feature_flag => false)
+
+ expect(aggregated_metrics_data).to eq('gmau_without_ff' => 2, 'gmau_enabled' => 3)
+ end
+ end
+ end
+ end
+
+ describe '.aggregated_metrics_weekly_data' do
+ subject(:aggregated_metrics_data) { described_class.aggregated_metrics_weekly_data }
+
+ before do
+ described_class.track_event(entity1, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity2, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity3, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity1, 'event2_slot', 2.days.ago)
+ described_class.track_event(entity2, 'event2_slot', 3.days.ago)
+ described_class.track_event(entity3, 'event2_slot', 3.days.ago)
+ described_class.track_event(entity1, 'event3_slot', 3.days.ago)
+ described_class.track_event(entity2, 'event3_slot', 3.days.ago)
+ described_class.track_event(entity2, 'event5_slot', 3.days.ago)
+
+ # events out of time scope
+ described_class.track_event(entity3, 'event2_slot', 8.days.ago)
+
+ # events in different slots
+ described_class.track_event(entity1, 'event4', 2.days.ago)
+ described_class.track_event(entity2, 'event4', 2.days.ago)
+ described_class.track_event(entity4, 'event4', 2.days.ago)
+ end
+
+ it_behaves_like 'aggregated_metrics_data'
+ end
+
+ describe '.aggregated_metrics_monthly_data' do
+ subject(:aggregated_metrics_data) { described_class.aggregated_metrics_monthly_data }
+
+ it_behaves_like 'aggregated_metrics_data' do
+ before do
+ described_class.track_event(entity1, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity2, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity3, 'event1_slot', 2.days.ago)
+ described_class.track_event(entity1, 'event2_slot', 2.days.ago)
+ described_class.track_event(entity2, 'event2_slot', 3.days.ago)
+ described_class.track_event(entity3, 'event2_slot', 3.days.ago)
+ described_class.track_event(entity1, 'event3_slot', 3.days.ago)
+ described_class.track_event(entity2, 'event3_slot', 10.days.ago)
+ described_class.track_event(entity2, 'event5_slot', 4.weeks.ago.advance(days: 1))
+
+ # events out of time scope
+ described_class.track_event(entity1, 'event5_slot', 4.weeks.ago.advance(days: -1))
+
+ # events in different slots
+ described_class.track_event(entity1, 'event4', 2.days.ago)
+ described_class.track_event(entity2, 'event4', 2.days.ago)
+ described_class.track_event(entity4, 'event4', 2.days.ago)
+ end
+ end
+
+ context 'Redis calls' do
+ let(:aggregated_metrics) do
+ [
+ { name: 'gmau_3', events: %w[event1_slot event2_slot event3_slot event5_slot], operator: "AND" }
+ ].map(&:with_indifferent_access)
+ end
+
+ let(:known_events) do
+ [
+ { name: 'event1_slot', redis_slot: "slot", category: 'category1', aggregation: "weekly" },
+ { name: 'event2_slot', redis_slot: "slot", category: 'category2', aggregation: "weekly" },
+ { name: 'event3_slot', redis_slot: "slot", category: 'category3', aggregation: "weekly" },
+ { name: 'event5_slot', redis_slot: "slot", category: 'category4', aggregation: "weekly" }
+ ].map(&:with_indifferent_access)
+ end
+
+ it 'caches intermediate operations' do
+ allow(described_class).to receive(:known_events).and_return(known_events)
+ allow(described_class).to receive(:aggregated_metrics).and_return(aggregated_metrics)
+
+ 4.downto(1) do |subset_size|
+ known_events.combination(subset_size).each do |events|
+ keys = described_class.send(:weekly_redis_keys, events: events, start_date: 4.weeks.ago.to_date, end_date: Date.current)
+ expect(Gitlab::Redis::HLL).to receive(:count).with(keys: keys).once.and_return(0)
+ end
+ end
+
+ subject
+ end
+ end
+ end
+ end
end
diff --git a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
index e08dc41d0cc..803eff05efe 100644
--- a/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/issue_activity_unique_counter_spec.rb
@@ -8,42 +8,8 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
let(:user3) { build(:user, id: 3) }
let(:time) { Time.zone.now }
- shared_examples 'tracks and counts action' do
- before do
- stub_application_setting(usage_ping_enabled: true)
- end
-
- def count_unique(date_from:, date_to:)
- Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to)
- end
-
- specify do
- aggregate_failures do
- expect(track_action(author: user1)).to be_truthy
- expect(track_action(author: user1)).to be_truthy
- expect(track_action(author: user2)).to be_truthy
- expect(track_action(author: user3, time: time - 3.days)).to be_truthy
-
- expect(count_unique(date_from: time, date_to: time)).to eq(2)
- expect(count_unique(date_from: time - 5.days, date_to: 1.day.since(time))).to eq(3)
- end
- end
-
- it 'does not track edit actions if author is not present' do
- expect(track_action(author: nil)).to be_nil
- end
-
- context 'when feature flag track_issue_activity_actions is disabled' do
- it 'does not track edit actions' do
- stub_feature_flags(track_issue_activity_actions: false)
-
- expect(track_action(author: user1)).to be_nil
- end
- end
- end
-
context 'for Issue title edit actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_TITLE_CHANGED }
def track_action(params)
@@ -53,7 +19,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue description edit actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_DESCRIPTION_CHANGED }
def track_action(params)
@@ -63,7 +29,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue assignee edit actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_ASSIGNEE_CHANGED }
def track_action(params)
@@ -73,7 +39,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make confidential actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_MADE_CONFIDENTIAL }
def track_action(params)
@@ -83,7 +49,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue make visible actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_MADE_VISIBLE }
def track_action(params)
@@ -93,7 +59,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue created actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_CREATED }
def track_action(params)
@@ -103,7 +69,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue closed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_CLOSED }
def track_action(params)
@@ -113,7 +79,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue reopened actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_REOPENED }
def track_action(params)
@@ -123,7 +89,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue label changed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_LABEL_CHANGED }
def track_action(params)
@@ -133,7 +99,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue cross-referenced actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_CROSS_REFERENCED }
def track_action(params)
@@ -143,7 +109,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue moved actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_MOVED }
def track_action(params)
@@ -153,7 +119,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue relate actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_RELATED }
def track_action(params)
@@ -163,7 +129,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unrelate actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_UNRELATED }
def track_action(params)
@@ -173,7 +139,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue marked as duplicate actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_MARKED_AS_DUPLICATE }
def track_action(params)
@@ -183,7 +149,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue locked actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_LOCKED }
def track_action(params)
@@ -193,7 +159,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue unlocked actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_UNLOCKED }
def track_action(params)
@@ -202,38 +168,8 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
end
- context 'for Issue added to epic actions' do
- it_behaves_like 'tracks and counts action' do
- let(:action) { described_class::ISSUE_ADDED_TO_EPIC}
-
- def track_action(params)
- described_class.track_issue_added_to_epic_action(**params)
- end
- end
- end
-
- context 'for Issue removed from epic actions' do
- it_behaves_like 'tracks and counts action' do
- let(:action) { described_class::ISSUE_REMOVED_FROM_EPIC}
-
- def track_action(params)
- described_class.track_issue_removed_from_epic_action(**params)
- end
- end
- end
-
- context 'for Issue changed epic actions' do
- it_behaves_like 'tracks and counts action' do
- let(:action) { described_class::ISSUE_CHANGED_EPIC}
-
- def track_action(params)
- described_class.track_issue_changed_epic_action(**params)
- end
- end
- end
-
context 'for Issue designs added actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_DESIGNS_ADDED }
def track_action(params)
@@ -243,7 +179,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs modified actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_DESIGNS_MODIFIED }
def track_action(params)
@@ -253,7 +189,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue designs removed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_DESIGNS_REMOVED }
def track_action(params)
@@ -263,7 +199,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue due date changed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_DUE_DATE_CHANGED }
def track_action(params)
@@ -273,7 +209,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time estimate changed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_TIME_ESTIMATE_CHANGED }
def track_action(params)
@@ -283,7 +219,7 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
context 'for Issue time spent changed actions' do
- it_behaves_like 'tracks and counts action' do
+ it_behaves_like 'a tracked issue edit event' do
let(:action) { described_class::ISSUE_TIME_SPENT_CHANGED }
def track_action(params)
@@ -292,6 +228,36 @@ RSpec.describe Gitlab::UsageDataCounters::IssueActivityUniqueCounter, :clean_git
end
end
+ context 'for Issue comment added actions' do
+ it_behaves_like 'a tracked issue edit event' do
+ let(:action) { described_class::ISSUE_COMMENT_ADDED }
+
+ def track_action(params)
+ described_class.track_issue_comment_added_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue comment edited actions' do
+ it_behaves_like 'a tracked issue edit event' do
+ let(:action) { described_class::ISSUE_COMMENT_EDITED }
+
+ def track_action(params)
+ described_class.track_issue_comment_edited_action(**params)
+ end
+ end
+ end
+
+ context 'for Issue comment removed actions' do
+ it_behaves_like 'a tracked issue edit event' do
+ let(:action) { described_class::ISSUE_COMMENT_REMOVED }
+
+ def track_action(params)
+ described_class.track_issue_comment_removed_action(**params)
+ end
+ end
+ end
+
it 'can return the count of actions per user deduplicated', :aggregate_failures do
described_class.track_issue_title_changed_action(author: user1)
described_class.track_issue_description_changed_action(author: user1)
diff --git a/spec/lib/gitlab/usage_data_counters/static_site_editor_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/static_site_editor_counter_spec.rb
index aaa576865f6..1bf5dad1c9f 100644
--- a/spec/lib/gitlab/usage_data_counters/static_site_editor_counter_spec.rb
+++ b/spec/lib/gitlab/usage_data_counters/static_site_editor_counter_spec.rb
@@ -4,7 +4,11 @@ require 'spec_helper'
RSpec.describe Gitlab::UsageDataCounters::StaticSiteEditorCounter do
it_behaves_like 'a redis usage counter', 'StaticSiteEditor', :views
+ it_behaves_like 'a redis usage counter', 'StaticSiteEditor', :commits
+ it_behaves_like 'a redis usage counter', 'StaticSiteEditor', :merge_requests
it_behaves_like 'a redis usage counter with totals', :static_site_editor,
- views: 3
+ views: 3,
+ commits: 4,
+ merge_requests: 5
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index f64fa2b868d..d305b2c5bfe 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -12,33 +12,37 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
describe '.uncached_data' do
- describe '.usage_activity_by_stage' do
- subject { described_class.uncached_data }
-
- it 'includes usage_activity_by_stage data' do
- is_expected.to include(:usage_activity_by_stage)
- is_expected.to include(:usage_activity_by_stage_monthly)
- expect(subject[:usage_activity_by_stage])
- .to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
- expect(subject[:usage_activity_by_stage_monthly])
- .to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
- end
-
- it 'clears memoized values' do
- allow(described_class).to receive(:clear_memoization)
+ subject { described_class.uncached_data }
+
+ it 'includes basic top and second level keys' do
+ is_expected.to include(:counts)
+ is_expected.to include(:counts_monthly)
+ is_expected.to include(:counts_weekly)
+ is_expected.to include(:license)
+ is_expected.to include(:settings)
+
+ # usage_activity_by_stage data
+ is_expected.to include(:usage_activity_by_stage)
+ is_expected.to include(:usage_activity_by_stage_monthly)
+ expect(subject[:usage_activity_by_stage])
+ .to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
+ expect(subject[:usage_activity_by_stage_monthly])
+ .to include(:configure, :create, :manage, :monitor, :plan, :release, :verify)
+ expect(subject[:usage_activity_by_stage][:create])
+ .not_to include(:merge_requests_users)
+ expect(subject[:usage_activity_by_stage_monthly][:create])
+ .to include(:merge_requests_users)
+ expect(subject[:counts_weekly]).to include(:aggregated_metrics)
+ expect(subject[:counts_monthly]).to include(:aggregated_metrics)
+ end
- subject
+ it 'clears memoized values' do
+ allow(described_class).to receive(:clear_memoization)
- described_class::CE_MEMOIZED_VALUES.each do |key|
- expect(described_class).to have_received(:clear_memoization).with(key)
- end
- end
+ subject
- it 'merge_requests_users is included only in montly counters' do
- expect(subject[:usage_activity_by_stage][:create])
- .not_to include(:merge_requests_users)
- expect(subject[:usage_activity_by_stage_monthly][:create])
- .to include(:merge_requests_users)
+ described_class::CE_MEMOIZED_VALUES.each do |key|
+ expect(described_class).to have_received(:clear_memoization).with(key)
end
end
@@ -48,7 +52,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
expect(described_class).to receive(:recorded_at).and_raise(Exception.new('Stopped calculating recorded_at'))
- expect { described_class.uncached_data }.to raise_error('Stopped calculating recorded_at')
+ expect { subject }.to raise_error('Stopped calculating recorded_at')
end
end
@@ -168,6 +172,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
omniauth:
{ providers: omniauth_providers }
)
+ allow(Devise).to receive(:omniauth_providers).and_return(%w(ldapmain ldapsecondary group_saml))
for_defined_days_back do
user = create(:user)
@@ -186,14 +191,14 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
groups: 2,
users_created: 6,
omniauth_providers: ['google_oauth2'],
- user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4 }
+ user_auth_by_provider: { 'group_saml' => 2, 'ldap' => 4, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 }
)
expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include(
events: 1,
groups: 1,
users_created: 3,
omniauth_providers: ['google_oauth2'],
- user_auth_by_provider: { 'group_saml' => 1, 'ldap' => 2 }
+ user_auth_by_provider: { 'group_saml' => 1, 'ldap' => 2, 'standard' => 0, 'two-factor' => 0, 'two-factor-via-u2f-device' => 0, "two-factor-via-webauthn-device" => 0 }
)
end
@@ -201,17 +206,25 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
for_defined_days_back do
user = create(:user)
+ create(:bulk_import, user: user)
+
%w(gitlab_project gitlab github bitbucket bitbucket_server gitea git manifest fogbugz phabricator).each do |type|
create(:project, import_type: type, creator_id: user.id)
end
jira_project = create(:project, creator_id: user.id)
create(:jira_import_state, :finished, project: jira_project)
+
+ create(:issue_csv_import, user: user)
end
expect(described_class.usage_activity_by_stage_manage({})).to include(
{
+ bulk_imports: {
+ gitlab: 2
+ },
projects_imported: {
+ total: 20,
gitlab_project: 2,
gitlab: 2,
github: 2,
@@ -224,13 +237,18 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
issues_imported: {
jira: 2,
fogbugz: 2,
- phabricator: 2
+ phabricator: 2,
+ csv: 2
}
}
)
expect(described_class.usage_activity_by_stage_manage(described_class.last_28_days_time_period)).to include(
{
+ bulk_imports: {
+ gitlab: 1
+ },
projects_imported: {
+ total: 10,
gitlab_project: 1,
gitlab: 1,
github: 1,
@@ -243,7 +261,8 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
issues_imported: {
jira: 1,
fogbugz: 1,
- phabricator: 1
+ phabricator: 1,
+ csv: 1
}
}
)
@@ -280,19 +299,29 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
create(:project, creator: user)
create(:clusters_applications_prometheus, :installed, cluster: cluster)
create(:project_tracing_setting)
+ create(:project_error_tracking_setting)
+ create(:incident)
+ create(:incident, alert_management_alert: create(:alert_management_alert))
end
expect(described_class.usage_activity_by_stage_monitor({})).to include(
clusters: 2,
clusters_applications_prometheus: 2,
operations_dashboard_default_dashboard: 2,
- projects_with_tracing_enabled: 2
+ projects_with_tracing_enabled: 2,
+ projects_with_error_tracking_enabled: 2,
+ projects_with_incidents: 4,
+ projects_with_alert_incidents: 2
)
+
expect(described_class.usage_activity_by_stage_monitor(described_class.last_28_days_time_period)).to include(
clusters: 1,
clusters_applications_prometheus: 1,
operations_dashboard_default_dashboard: 1,
- projects_with_tracing_enabled: 1
+ projects_with_tracing_enabled: 1,
+ projects_with_error_tracking_enabled: 1,
+ projects_with_incidents: 2,
+ projects_with_alert_incidents: 1
)
end
end
@@ -446,9 +475,11 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(count_data[:projects_with_error_tracking_enabled]).to eq(1)
expect(count_data[:projects_with_tracing_enabled]).to eq(1)
expect(count_data[:projects_with_alerts_service_enabled]).to eq(1)
+ expect(count_data[:projects_with_enabled_alert_integrations]).to eq(1)
expect(count_data[:projects_with_prometheus_alerts]).to eq(2)
expect(count_data[:projects_with_terraform_reports]).to eq(2)
expect(count_data[:projects_with_terraform_states]).to eq(2)
+ expect(count_data[:projects_with_alerts_created]).to eq(1)
expect(count_data[:protected_branches]).to eq(2)
expect(count_data[:protected_branches_except_default]).to eq(1)
expect(count_data[:terraform_reports]).to eq(6)
@@ -532,13 +563,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
subject { described_class.data[:counts] }
it 'gathers usage data' do
- expect(subject[:projects_with_expiration_policy_enabled]).to eq 22
- expect(subject[:projects_with_expiration_policy_disabled]).to eq 1
+ expect(subject[:projects_with_expiration_policy_enabled]).to eq 18
+ expect(subject[:projects_with_expiration_policy_disabled]).to eq 5
expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_unset]).to eq 1
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 16
+ expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 12
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
@@ -546,9 +577,9 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
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 18
+ expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_90d]).to eq 14
- expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 18
+ expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 14
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
@@ -577,9 +608,22 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
describe '.system_usage_data_monthly' do
let_it_be(:project) { create(:project) }
- let!(:ud) { build(:usage_data) }
before do
+ project = create(:project)
+ env = create(:environment)
+ create(:package, project: project, created_at: 3.days.ago)
+ create(:package, created_at: 2.months.ago, project: project)
+
+ [3, 31].each do |n|
+ deployment_options = { created_at: n.days.ago, project: env.project, environment: env }
+ create(:deployment, :failed, deployment_options)
+ create(:deployment, :success, deployment_options)
+ create(:project_snippet, project: project, created_at: n.days.ago)
+ create(:personal_snippet, created_at: n.days.ago)
+ create(:alert_management_alert, project: project, created_at: n.days.ago)
+ end
+
stub_application_setting(self_monitoring_project: project)
for_defined_days_back do
@@ -595,10 +639,11 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
expect(counts_monthly[:deployments]).to eq(2)
expect(counts_monthly[:successful_deployments]).to eq(1)
expect(counts_monthly[:failed_deployments]).to eq(1)
- expect(counts_monthly[:snippets]).to eq(3)
+ expect(counts_monthly[:snippets]).to eq(2)
expect(counts_monthly[:personal_snippets]).to eq(1)
- expect(counts_monthly[:project_snippets]).to eq(2)
- expect(counts_monthly[:packages]).to eq(3)
+ expect(counts_monthly[:project_snippets]).to eq(1)
+ expect(counts_monthly[:projects_with_alerts_created]).to eq(1)
+ expect(counts_monthly[:packages]).to eq(1)
expect(counts_monthly[:promoted_issues]).to eq(1)
end
end
@@ -1047,6 +1092,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
let(:user1) { build(:user, id: 1) }
let(:user2) { build(:user, id: 2) }
let(:user3) { build(:user, id: 3) }
+ let(:user4) { build(:user, id: 4) }
before do
counter = Gitlab::UsageDataCounters::TrackUniqueEvents
@@ -1061,6 +1107,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
counter.track_event(event_action: :pushed, event_target: project, author_id: 4, time: time - 3.days)
counter.track_event(event_action: :created, event_target: wiki, author_id: 3)
counter.track_event(event_action: :created, event_target: design, author_id: 3)
+ counter.track_event(event_action: :created, event_target: design, author_id: 4)
counter = Gitlab::UsageDataCounters::EditorUniqueCounter
@@ -1080,9 +1127,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
it 'returns the distinct count of user actions within the specified time period' do
expect(described_class.action_monthly_active_users(time_period)).to eq(
{
- action_monthly_active_users_design_management: 1,
+ action_monthly_active_users_design_management: 2,
action_monthly_active_users_project_repo: 3,
action_monthly_active_users_wiki_repo: 1,
+ action_monthly_active_users_git_write: 4,
action_monthly_active_users_web_ide_edit: 2,
action_monthly_active_users_sfe_edit: 2,
action_monthly_active_users_snippet_editor_edit: 2,
@@ -1187,7 +1235,7 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
subject { described_class.redis_hll_counters }
let(:categories) { ::Gitlab::UsageDataCounters::HLLRedisCounter.categories }
- let(:ineligible_total_categories) { %w[source_code testing] }
+ let(:ineligible_total_categories) { %w[source_code testing ci_secrets_management] }
it 'has all known_events' do
expect(subject).to have_key(:redis_hll_counters)
@@ -1208,6 +1256,48 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
+ describe 'aggregated_metrics' do
+ shared_examples 'aggregated_metrics_for_time_range' do
+ context 'with product_analytics_aggregated_metrics feature flag on' do
+ before do
+ stub_feature_flags(product_analytics_aggregated_metrics: true)
+ end
+
+ it 'uses ::Gitlab::UsageDataCounters::HLLRedisCounter#aggregated_metrics_data', :aggregate_failures do
+ expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(aggregated_metrics_data_method).and_return(global_search_gmau: 123)
+ expect(aggregated_metrics_payload).to eq(aggregated_metrics: { global_search_gmau: 123 })
+ end
+ end
+
+ context 'with product_analytics_aggregated_metrics feature flag off' do
+ before do
+ stub_feature_flags(product_analytics_aggregated_metrics: false)
+ end
+
+ it 'returns empty hash', :aggregate_failures do
+ expect(::Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(aggregated_metrics_data_method)
+ expect(aggregated_metrics_payload).to be {}
+ end
+ end
+ end
+
+ describe '.aggregated_metrics_weekly' do
+ subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_weekly }
+
+ let(:aggregated_metrics_data_method) { :aggregated_metrics_weekly_data }
+
+ it_behaves_like 'aggregated_metrics_for_time_range'
+ end
+
+ describe '.aggregated_metrics_monthly' do
+ subject(:aggregated_metrics_payload) { described_class.aggregated_metrics_monthly }
+
+ let(:aggregated_metrics_data_method) { :aggregated_metrics_monthly_data }
+
+ it_behaves_like 'aggregated_metrics_for_time_range'
+ end
+ end
+
describe '.service_desk_counts' do
subject { described_class.send(:service_desk_counts) }
diff --git a/spec/controllers/concerns/controller_with_feature_category_spec.rb b/spec/lib/gitlab/with_feature_category_spec.rb
index 55e84755f5c..b6fe1c84b26 100644
--- a/spec/controllers/concerns/controller_with_feature_category_spec.rb
+++ b/spec/lib/gitlab/with_feature_category_spec.rb
@@ -1,13 +1,13 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-require_relative "../../../app/controllers/concerns/controller_with_feature_category"
+require_relative "../../../lib/gitlab/with_feature_category"
-RSpec.describe ControllerWithFeatureCategory do
+RSpec.describe Gitlab::WithFeatureCategory do
describe ".feature_category_for_action" do
let(:base_controller) do
Class.new do
- include ControllerWithFeatureCategory
+ include ::Gitlab::WithFeatureCategory
end
end
@@ -56,5 +56,14 @@ RSpec.describe ControllerWithFeatureCategory do
end
end.to raise_error(ArgumentError, "Actions have multiple feature categories: world")
end
+
+ it "does not raise an error when multiple calls define the same action and feature category" do
+ expect do
+ Class.new(base_controller) do
+ feature_category :hello, [:world]
+ feature_category :hello, ["world"]
+ end
+ end.not_to raise_error
+ end
end
end
diff --git a/spec/lib/quality/test_level_spec.rb b/spec/lib/quality/test_level_spec.rb
index 0b113e8b63a..0239c974755 100644
--- a/spec/lib/quality/test_level_spec.rb
+++ b/spec/lib/quality/test_level_spec.rb
@@ -18,6 +18,13 @@ RSpec.describe Quality::TestLevel do
end
end
+ context 'when level is frontend_fixture' do
+ it 'returns a pattern' do
+ expect(subject.pattern(:frontend_fixture))
+ .to eq("spec/{frontend/fixtures}{,/**/}*.rb")
+ end
+ end
+
context 'when level is unit' do
it 'returns a pattern' do
expect(subject.pattern(:unit))
@@ -86,6 +93,13 @@ RSpec.describe Quality::TestLevel do
end
end
+ context 'when level is frontend_fixture' do
+ it 'returns a regexp' do
+ expect(subject.regexp(:frontend_fixture))
+ .to eq(%r{spec/(frontend/fixtures)})
+ end
+ end
+
context 'when level is unit' do
it 'returns a regexp' do
expect(subject.regexp(:unit))
@@ -144,6 +158,10 @@ RSpec.describe Quality::TestLevel do
expect(subject.level_for('spec/models/abuse_report_spec.rb')).to eq(:unit)
end
+ it 'returns the correct level for a frontend fixture test' do
+ expect(subject.level_for('spec/frontend/fixtures/pipelines.rb')).to eq(:frontend_fixture)
+ end
+
it 'returns the correct level for a tooling test' do
expect(subject.level_for('spec/tooling/lib/tooling/test_file_finder_spec.rb')).to eq(:unit)
end
diff --git a/spec/mailers/devise_mailer_spec.rb b/spec/mailers/devise_mailer_spec.rb
index 2ee15308400..c9dfee8255d 100644
--- a/spec/mailers/devise_mailer_spec.rb
+++ b/spec/mailers/devise_mailer_spec.rb
@@ -64,4 +64,34 @@ RSpec.describe DeviseMailer do
is_expected.to have_body_text /#{Gitlab.config.gitlab.url}/
end
end
+
+ describe '#user_admin_approval' do
+ subject { described_class.user_admin_approval(user) }
+
+ let_it_be(:user) { create(:user) }
+
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like 'a user cannot unsubscribe through footer link'
+
+ it 'is sent to the user' do
+ is_expected.to deliver_to user.email
+ end
+
+ it 'has the correct subject' do
+ is_expected.to have_subject 'Welcome to GitLab!'
+ end
+
+ it 'greets the user' do
+ is_expected.to have_body_text /Hi #{user.name}!/
+ end
+
+ it 'includes the correct content' do
+ is_expected.to have_body_text /Your GitLab account request has been approved!/
+ end
+
+ it 'includes a link to GitLab' do
+ is_expected.to have_link(Gitlab.config.gitlab.url)
+ end
+ end
end
diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb
index 9235a946394..412cdff3aba 100644
--- a/spec/mailers/emails/merge_requests_spec.rb
+++ b/spec/mailers/emails/merge_requests_spec.rb
@@ -52,7 +52,8 @@ RSpec.describe Emails::MergeRequests do
it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") }
it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) }
- it { expect(subject).to have_content('Your CSV export of 10 merge requests from project')}
+ it { expect(subject.html_part).to have_content("Your CSV export of 10 merge requests from project") }
+ it { expect(subject.text_part).to have_content("Your CSV export of 10 merge requests from project") }
context 'when truncated' do
let(:export_status) do
diff --git a/spec/mailers/emails/projects_spec.rb b/spec/mailers/emails/projects_spec.rb
index aa5947bf68e..6c23625d4a3 100644
--- a/spec/mailers/emails/projects_spec.rb
+++ b/spec/mailers/emails/projects_spec.rb
@@ -32,19 +32,13 @@ RSpec.describe Emails::Projects do
describe '#prometheus_alert_fired_email' do
let(:default_title) { Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE }
let(:payload) { { 'startsAt' => Time.now.rfc3339 } }
- let(:alert_attributes) { build(:alert_management_alert, :from_payload, payload: payload, project: project).attributes }
+ let(:alert) { create(:alert_management_alert, :from_payload, payload: payload, project: project) }
subject do
- Notify.prometheus_alert_fired_email(project.id, user.id, alert_attributes)
+ Notify.prometheus_alert_fired_email(project, user, alert)
end
- context 'missing required attributes' do
- let(:alert_attributes) { build(:alert_management_alert, :prometheus, :from_payload, payload: payload, project: project).attributes }
-
- it_behaves_like 'no email'
- end
-
- context 'with minimum required attributes' do
+ context 'with empty payload' do
let(:payload) { {} }
it_behaves_like 'an email sent from GitLab'
@@ -58,6 +52,7 @@ RSpec.describe Emails::Projects do
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text(alert.details_url)
is_expected.not_to have_body_text('Description:')
is_expected.not_to have_body_text('Environment:')
is_expected.not_to have_body_text('Metric:')
@@ -78,6 +73,7 @@ RSpec.describe Emails::Projects do
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text(alert.details_url)
is_expected.to have_body_text('Description:')
is_expected.to have_body_text('alert description')
is_expected.not_to have_body_text('Environment:')
@@ -101,6 +97,7 @@ RSpec.describe Emails::Projects do
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text(alert.details_url)
is_expected.to have_body_text('Environment:')
is_expected.to have_body_text(environment.name)
is_expected.not_to have_body_text('Description:')
@@ -112,7 +109,7 @@ RSpec.describe Emails::Projects do
let_it_be(:prometheus_alert) { create(:prometheus_alert, project: project) }
let_it_be(:environment) { prometheus_alert.environment }
- let(:alert_attributes) { build(:alert_management_alert, :prometheus, :from_payload, payload: payload, project: project).attributes }
+ let(:alert) { create(:alert_management_alert, :prometheus, :from_payload, payload: payload, project: project) }
let(:title) { "#{prometheus_alert.title} #{prometheus_alert.computed_operator} #{prometheus_alert.threshold}" }
let(:metrics_url) { metrics_project_environment_url(project, environment) }
@@ -135,6 +132,7 @@ RSpec.describe Emails::Projects do
it 'has expected content' do
is_expected.to have_body_text('An alert has been triggered')
is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text(alert.details_url)
is_expected.to have_body_text('Environment:')
is_expected.to have_body_text(environment.name)
is_expected.to have_body_text('Metric:')
@@ -143,5 +141,23 @@ RSpec.describe Emails::Projects do
is_expected.not_to have_body_text('Description:')
end
end
+
+ context 'resolved' do
+ let_it_be(:alert) { create(:alert_management_alert, :resolved, project: project) }
+
+ 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 'has expected subject' do
+ is_expected.to have_subject("#{project.name} | Alert: #{alert.title}")
+ end
+
+ it 'has expected content' do
+ is_expected.to have_body_text('An alert has been resolved')
+ is_expected.to have_body_text(project.full_path)
+ is_expected.to have_body_text(alert.details_url)
+ end
+ end
end
end
diff --git a/spec/mailers/emails/service_desk_spec.rb b/spec/mailers/emails/service_desk_spec.rb
index 842f82539cb..7d04b373be6 100644
--- a/spec/mailers/emails/service_desk_spec.rb
+++ b/spec/mailers/emails/service_desk_spec.rb
@@ -183,6 +183,25 @@ RSpec.describe Emails::ServiceDesk do
it_behaves_like 'handle template content', 'new_note'
end
+
+ context 'with upload link in the note' do
+ let_it_be(:upload_path) { '/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg' }
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "a new comment with [file](#{upload_path})") }
+
+ let(:template_content) { 'some text %{ NOTE_TEXT }' }
+ let(:expected_body) { %Q(some text a new comment with <a href="#{project.web_url}#{upload_path}" data-link="true" class="gfm">file</a>) }
+
+ it_behaves_like 'handle template content', 'new_note'
+ end
+
+ context 'with all-user reference in a an external author comment' do
+ let_it_be(:note) { create(:note_on_issue, noteable: issue, project: project, note: "Hey @all, just a ping", author: User.support_bot) }
+
+ let(:template_content) { 'some text %{ NOTE_TEXT }' }
+ let(:expected_body) { 'Hey , just a ping' }
+
+ it_behaves_like 'handle template content', 'new_note'
+ end
end
end
end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 8604939ead9..3cc5f202b1f 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -619,6 +619,7 @@ RSpec.describe Notify do
let(:mailer) do
mailer = described_class.new
mailer.instance_variable_set(:@note, mail_thread_note)
+ mailer.instance_variable_set(:@target_url, "https://some.link")
mailer
end
@@ -887,96 +888,30 @@ RSpec.describe Notify do
subject { described_class.member_invited_email('project', project_member.id, project_member.invite_token) }
- context 'when invite_email_experiment is disabled' do
- before do
- stub_feature_flags(invite_email_experiment: false)
- end
-
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like "a user cannot unsubscribe through footer link"
- it_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like "a user cannot unsubscribe through footer link"
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'does not render a manage notifications link'
+ context 'when there is an inviter' do
it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{project.full_name} project"
+ is_expected.to have_subject "#{inviter.name} invited you to join GitLab"
is_expected.to have_body_text project.full_name
- is_expected.to have_body_text project_member.human_access
+ is_expected.to have_body_text project_member.human_access.downcase
is_expected.to have_body_text project_member.invite_token
end
-
- context 'when member is invited via an email address' do
- it 'does add a param to the invite link' do
- is_expected.to have_body_text 'new_user_invite=control'
- end
-
- it 'tracks an event' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Acquisition::Experiment::InviteEmail',
- 'sent',
- property: 'control_group'
- )
-
- subject.deliver_now
- end
- end
-
- context 'when member is already a user' do
- let(:project_member) { invite_to_project(project, inviter: maintainer, user: create(:user)) }
-
- it 'does not add a param to the invite link' do
- is_expected.not_to have_body_text 'new_user_invite'
- end
-
- it 'does not track an event' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
- subject.deliver_now
- end
- end
end
- context 'when invite_email_experiment is enabled' do
- before do
- stub_feature_flags(invite_email_experiment: true)
- end
-
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like "a user cannot unsubscribe through footer link"
-
- context 'when there is no inviter' do
- let(:inviter) { nil }
-
- it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{project.full_name} project"
- is_expected.to have_body_text project.full_name
- is_expected.to have_body_text project_member.human_access.downcase
- is_expected.to have_body_text project_member.invite_token
- end
- end
+ context 'when there is no inviter' do
+ let(:inviter) { nil }
- context 'when there is an inviter' do
- it 'contains all the useful information' do
- is_expected.to have_subject "#{inviter.name} invited you to join GitLab"
- is_expected.to have_body_text project.full_name
- is_expected.to have_body_text project_member.human_access.downcase
- is_expected.to have_body_text project_member.invite_token
- end
- end
-
- it 'adds a param to the invite link' do
- is_expected.to have_body_text 'new_user_invite=experiment'
- end
-
- it 'tracks an event' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Acquisition::Experiment::InviteEmail',
- 'sent',
- property: 'experiment_group'
- )
-
- subject.deliver_now
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{project.full_name} project"
+ is_expected.to have_body_text project.full_name
+ is_expected.to have_body_text project_member.human_access.downcase
+ is_expected.to have_body_text project_member.invite_token
end
end
end
@@ -1547,95 +1482,31 @@ RSpec.describe Notify do
end
end
- context 'when invite_email_experiment is disabled' do
- before do
- stub_feature_flags(invite_email_experiment: false)
- end
-
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like "a user cannot unsubscribe through footer link"
- it_behaves_like 'appearance header and footer enabled'
- it_behaves_like 'appearance header and footer not enabled'
- it_behaves_like 'it requires a group'
+ it_behaves_like 'an email sent from GitLab'
+ it_behaves_like 'it should not have Gmail Actions links'
+ it_behaves_like "a user cannot unsubscribe through footer link"
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'it requires a group'
+ it_behaves_like 'does not render a manage notifications link'
+ context 'when there is an inviter' do
it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{group.name} group"
+ is_expected.to have_subject "#{group_member.created_by.name} invited you to join GitLab"
is_expected.to have_body_text group.name
- is_expected.to have_body_text group.web_url
- is_expected.to have_body_text group_member.human_access
+ is_expected.to have_body_text group_member.human_access.downcase
is_expected.to have_body_text group_member.invite_token
end
-
- context 'when member is invited via an email address' do
- it 'does add a param to the invite link' do
- is_expected.to have_body_text 'new_user_invite=control'
- end
-
- it 'tracks an event' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Acquisition::Experiment::InviteEmail',
- 'sent',
- property: 'control_group'
- )
-
- subject.deliver_now
- end
- end
-
- context 'when member is already a user' do
- let(:group_member) { invite_to_group(group, inviter: owner, user: create(:user)) }
-
- it 'does not add a param to the invite link' do
- is_expected.not_to have_body_text 'new_user_invite'
- end
-
- it 'does not track an event' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
- subject.deliver_now
- end
- end
end
- context 'when invite_email_experiment is enabled' do
- it_behaves_like 'an email sent from GitLab'
- it_behaves_like 'it should not have Gmail Actions links'
- it_behaves_like "a user cannot unsubscribe through footer link"
- it_behaves_like 'it requires a group'
-
- context 'when there is no inviter' do
- let(:inviter) { nil }
-
- it 'contains all the useful information' do
- is_expected.to have_subject "Invitation to join the #{group.name} group"
- is_expected.to have_body_text group.name
- is_expected.to have_body_text group_member.human_access.downcase
- is_expected.to have_body_text group_member.invite_token
- end
- end
+ context 'when there is no inviter' do
+ let(:inviter) { nil }
- context 'when there is an inviter' do
- it 'contains all the useful information' do
- is_expected.to have_subject "#{group_member.created_by.name} invited you to join GitLab"
- is_expected.to have_body_text group.name
- is_expected.to have_body_text group_member.human_access.downcase
- is_expected.to have_body_text group_member.invite_token
- end
- end
-
- it 'does add a param to the invite link' do
- is_expected.to have_body_text 'new_user_invite'
- end
-
- it 'tracks an event' do
- expect(Gitlab::Tracking).to receive(:event).with(
- 'Growth::Acquisition::Experiment::InviteEmail',
- 'sent',
- property: 'experiment_group'
- )
-
- subject.deliver_now
+ it 'contains all the useful information' do
+ is_expected.to have_subject "Invitation to join the #{group.name} group"
+ is_expected.to have_body_text group.name
+ is_expected.to have_body_text group_member.human_access.downcase
+ is_expected.to have_body_text group_member.invite_token
end
end
end
diff --git a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
index 196f5ead8d2..11398685549 100644
--- a/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
+++ b/spec/migrations/20190924152703_migrate_issue_trackers_data_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20190924152703_migrate_issue_trackers_data.rb')
+require_migration!('migrate_issue_trackers_data')
RSpec.describe MigrateIssueTrackersData do
let(:services) { table(:services) }
diff --git a/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb b/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb
index e2eacc00e5a..731bc923910 100644
--- a/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb
+++ b/spec/migrations/20191015154408_drop_merge_requests_require_code_owner_approval_from_projects_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20191015154408_drop_merge_requests_require_code_owner_approval_from_projects.rb')
+require_migration!('drop_merge_requests_require_code_owner_approval_from_projects')
RSpec.describe DropMergeRequestsRequireCodeOwnerApprovalFromProjects do
let(:projects_table) { table(:projects) }
diff --git a/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb b/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb
new file mode 100644
index 00000000000..c1fbde69100
--- /dev/null
+++ b/spec/migrations/20201027002551_migrate_services_to_http_integrations_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20201027002551_migrate_services_to_http_integrations.rb')
+
+RSpec.describe MigrateServicesToHttpIntegrations do
+ let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') }
+ let!(:project) { table(:projects).create!(id: 1, namespace_id: namespace.id) }
+ let!(:alert_service) { table(:services).create!(type: 'AlertsService', project_id: project.id, active: true) }
+ let!(:alert_service_data) { table(:alerts_service_data).create!(service_id: alert_service.id, encrypted_token: 'test', encrypted_token_iv: 'test')}
+ let(:http_integrations) { table(:alert_management_http_integrations) }
+
+ describe '#up' do
+ it 'creates the http integrations from the alert services', :aggregate_failures do
+ expect { migrate! }.to change { http_integrations.count }.by(1)
+
+ http_integration = http_integrations.last
+ expect(http_integration.project_id).to eq(alert_service.project_id)
+ expect(http_integration.encrypted_token).to eq(alert_service_data.encrypted_token)
+ expect(http_integration.encrypted_token_iv).to eq(alert_service_data.encrypted_token_iv)
+ expect(http_integration.active).to eq(alert_service.active)
+ expect(http_integration.name).to eq(described_class::SERVICE_NAMES_IDENTIFIER[:name])
+ expect(http_integration.endpoint_identifier).to eq(described_class::SERVICE_NAMES_IDENTIFIER[:identifier])
+ end
+ end
+end
diff --git a/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb b/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb
new file mode 100644
index 00000000000..658b26b1c49
--- /dev/null
+++ b/spec/migrations/20201028182809_backfill_jira_tracker_deployment_type2_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201028182809_backfill_jira_tracker_deployment_type2.rb')
+
+RSpec.describe BackfillJiraTrackerDeploymentType2, :sidekiq, schema: 20201028182809 do
+ let(:services) { table(:services) }
+ let(:jira_tracker_data) { table(:jira_tracker_data) }
+ let(:migration) { described_class.new }
+ let(:batch_interval) { described_class::DELAY_INTERVAL }
+
+ describe '#up' do
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+
+ active_service = services.create!(type: 'JiraService', active: true)
+ inactive_service = services.create!(type: 'JiraService', active: false)
+
+ jira_tracker_data.create!(id: 1, service_id: active_service.id, deployment_type: 0)
+ jira_tracker_data.create!(id: 2, service_id: active_service.id, deployment_type: 1)
+ jira_tracker_data.create!(id: 3, service_id: inactive_service.id, deployment_type: 2)
+ jira_tracker_data.create!(id: 4, service_id: inactive_service.id, deployment_type: 0)
+ jira_tracker_data.create!(id: 5, service_id: active_service.id, deployment_type: 0)
+ end
+
+ it 'schedules BackfillJiraTrackerDeploymentType2 background jobs' do
+ Sidekiq::Testing.fake! do
+ freeze_time do
+ migration.up
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval, 1, 4)
+ expect(described_class::MIGRATION).to be_scheduled_delayed_migration(batch_interval * 2, 5, 5)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb b/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb
index 523c8864b63..382ce2e1da4 100644
--- a/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb
+++ b/spec/migrations/cap_designs_filename_length_to_new_limit_spec.rb
@@ -32,17 +32,19 @@ RSpec.describe CapDesignsFilenameLengthToNewLimit, :migration, schema: 202005281
end
it 'correctly sets filenames that are above the limit' do
- [
+ designs = [
filename_below_limit,
filename_at_limit,
filename_above_limit
- ].each(&method(:create_design))
+ ].map(&method(:create_design))
migrate!
- expect(designs.find(1).filename).to eq(filename_below_limit)
- expect(designs.find(2).filename).to eq(filename_at_limit)
- expect(designs.find(3).filename).to eq([described_class::MODIFIED_NAME, 3, described_class::MODIFIED_EXTENSION].join)
+ designs.each(&:reload)
+
+ expect(designs[0].filename).to eq(filename_below_limit)
+ expect(designs[1].filename).to eq(filename_at_limit)
+ expect(designs[2].filename).to eq([described_class::MODIFIED_NAME, designs[2].id, described_class::MODIFIED_EXTENSION].join)
end
it 'runs after filename limit has been set' do
diff --git a/spec/migrations/deduplicate_epic_iids_spec.rb b/spec/migrations/deduplicate_epic_iids_spec.rb
new file mode 100644
index 00000000000..8afb8b06948
--- /dev/null
+++ b/spec/migrations/deduplicate_epic_iids_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201106134950_deduplicate_epic_iids.rb')
+
+RSpec.describe DeduplicateEpicIids, :migration, schema: 20201106082723 do
+ let(:routes) { table(:routes) }
+ let(:epics) { table(:epics) }
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+
+ let!(:group) { create_group('foo') }
+ let!(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') }
+ let!(:dup_epic1) { epics.create!(iid: 1, title: 'epic 1', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') }
+ let!(:dup_epic2) { epics.create!(iid: 1, title: 'epic 2', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') }
+ let!(:dup_epic3) { epics.create!(iid: 1, title: 'epic 3', group_id: group.id, author_id: user.id, created_at: Time.now, updated_at: Time.now, title_html: 'any') }
+
+ it 'deduplicates epic iids', :aggregate_failures do
+ duplicate_epics_count = epics.where(iid: 1, group_id: group.id).count
+ expect(duplicate_epics_count).to eq 3
+
+ migrate!
+
+ duplicate_epics_count = epics.where(iid: 1, group_id: group.id).count
+ expect(duplicate_epics_count).to eq 1
+ expect(dup_epic1.reload.iid).to eq 1
+ expect(dup_epic2.reload.iid).to eq 2
+ expect(dup_epic3.reload.iid).to eq 3
+ end
+
+ def create_group(path)
+ namespaces.create!(name: path, path: path, type: 'Group').tap do |namespace|
+ routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace')
+ end
+ end
+end
diff --git a/spec/migrations/generate_ci_jwt_signing_key_spec.rb b/spec/migrations/generate_ci_jwt_signing_key_spec.rb
new file mode 100644
index 00000000000..4cfaa8701aa
--- /dev/null
+++ b/spec/migrations/generate_ci_jwt_signing_key_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'migrate', '20201008013434_generate_ci_jwt_signing_key.rb')
+
+RSpec.describe GenerateCiJwtSigningKey do
+ let(:application_settings) do
+ Class.new(ActiveRecord::Base) do
+ self.table_name = 'application_settings'
+
+ attr_encrypted :ci_jwt_signing_key, {
+ mode: :per_attribute_iv,
+ key: Rails.application.secrets.db_key_base[0..31],
+ algorithm: 'aes-256-gcm',
+ encode: true
+ }
+ end
+ end
+
+ it 'generates JWT signing key' do
+ application_settings.create!
+
+ reversible_migration do |migration|
+ migration.before -> {
+ settings = application_settings.first
+
+ expect(settings.ci_jwt_signing_key).to be_nil
+ expect(settings.encrypted_ci_jwt_signing_key).to be_nil
+ expect(settings.encrypted_ci_jwt_signing_key_iv).to be_nil
+ }
+
+ migration.after -> {
+ settings = application_settings.first
+
+ expect(settings.encrypted_ci_jwt_signing_key).to be_present
+ expect(settings.encrypted_ci_jwt_signing_key_iv).to be_present
+ expect { OpenSSL::PKey::RSA.new(settings.ci_jwt_signing_key) }.not_to raise_error
+ }
+ end
+ end
+end
diff --git a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb b/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
index 92a49046193..699e9507f50 100644
--- a/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
+++ b/spec/migrations/migrate_discussion_id_on_promoted_epics_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe MigrateDiscussionIdOnPromotedEpics do
end
def create_epic
- epics.create!(author_id: user.id, iid: 1,
+ epics.create!(author_id: user.id, iid: epics.maximum(:iid).to_i + 1,
group_id: namespace.id,
title: 'Epic with discussion',
title_html: 'Epic with discussion')
diff --git a/spec/migrations/rename_sitemap_namespace_spec.rb b/spec/migrations/rename_sitemap_namespace_spec.rb
new file mode 100644
index 00000000000..83f0721c600
--- /dev/null
+++ b/spec/migrations/rename_sitemap_namespace_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201102112206_rename_sitemap_namespace.rb')
+
+RSpec.describe RenameSitemapNamespace do
+ let(:namespaces) { table(:namespaces) }
+ let(:routes) { table(:routes) }
+ let(:sitemap_path) { 'sitemap' }
+
+ it 'correctly run #up and #down' do
+ create_namespace(sitemap_path)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path)
+ }
+
+ migration.after -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0')
+ }
+ end
+ end
+
+ def create_namespace(path)
+ namespaces.create!(name: path, path: path).tap do |namespace|
+ routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace')
+ end
+ end
+end
diff --git a/spec/migrations/rename_sitemap_root_namespaces_spec.rb b/spec/migrations/rename_sitemap_root_namespaces_spec.rb
new file mode 100644
index 00000000000..7cbf27849b0
--- /dev/null
+++ b/spec/migrations/rename_sitemap_root_namespaces_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20201019094741_rename_sitemap_root_namespaces.rb')
+
+RSpec.describe RenameSitemapRootNamespaces do
+ let(:namespaces) { table(:namespaces) }
+ let(:routes) { table(:routes) }
+ let(:sitemap_path) { 'sitemap.xml' }
+ let(:sitemap_gz_path) { 'sitemap.xml.gz' }
+ let(:other_path1) { 'sitemap.xmlfoo' }
+ let(:other_path2) { 'foositemap.xml' }
+
+ it 'correctly run #up and #down' do
+ create_namespace(sitemap_path)
+ create_namespace(sitemap_gz_path)
+ create_namespace(other_path1)
+ create_namespace(other_path2)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path, sitemap_gz_path, other_path1, other_path2)
+ }
+
+ migration.after -> {
+ expect(namespaces.pluck(:path)).to contain_exactly(sitemap_path + '0', sitemap_gz_path + '0', other_path1, other_path2)
+ }
+ end
+ end
+
+ def create_namespace(path)
+ namespaces.create!(name: path, path: path).tap do |namespace|
+ routes.create!(path: namespace.path, name: namespace.name, source_id: namespace.id, source_type: 'Namespace')
+ end
+ end
+end
diff --git a/spec/migrations/reseed_merge_trains_enabled_spec.rb b/spec/migrations/reseed_merge_trains_enabled_spec.rb
new file mode 100644
index 00000000000..71ef0b47da9
--- /dev/null
+++ b/spec/migrations/reseed_merge_trains_enabled_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20201112195322_reseed_merge_trains_enabled.rb')
+
+RSpec.describe ReseedMergeTrainsEnabled do
+ describe 'migrate' do
+ let(:project_ci_cd_settings) { table(:project_ci_cd_settings) }
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+
+ context 'when on Gitlab.com' do
+ before do
+ namespace = namespaces.create!(name: 'hello', path: 'hello/')
+ project1 = projects.create!(namespace_id: namespace.id)
+ project2 = projects.create!(namespace_id: namespace.id)
+ project_ci_cd_settings.create!(project_id: project1.id, merge_pipelines_enabled: true)
+ project_ci_cd_settings.create!(project_id: project2.id, merge_pipelines_enabled: false)
+ end
+
+ it 'updates merge_trains_enabled to true for where merge_pipelines_enabled is true' do
+ expect { migrate! }.to change(project_ci_cd_settings.where(merge_trains_enabled: true), :count).by(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/schedule_blocked_by_links_replacement_spec.rb b/spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb
index 36610507921..9cd1b98fbd7 100644
--- a/spec/migrations/schedule_blocked_by_links_replacement_spec.rb
+++ b/spec/migrations/schedule_blocked_by_links_replacement_second_try_spec.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
require 'spec_helper'
-require Rails.root.join('db', 'post_migrate', '20201015073808_schedule_blocked_by_links_replacement')
+require Rails.root.join('db', 'post_migrate', '20201102073808_schedule_blocked_by_links_replacement_second_try')
-RSpec.describe ScheduleBlockedByLinksReplacement do
+RSpec.describe ScheduleBlockedByLinksReplacementSecondTry do
let(:namespace) { table(:namespaces).create!(name: 'gitlab', path: 'gitlab-org') }
let(:project) { table(:projects).create!(namespace_id: namespace.id, name: 'gitlab') }
let(:issue1) { table(:issues).create!(project_id: project.id, title: 'a') }
diff --git a/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb b/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb
new file mode 100644
index 00000000000..fa8dd38619a
--- /dev/null
+++ b/spec/migrations/schedule_merge_request_cleanup_schedules_backfill_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require_migration!
+
+RSpec.describe ScheduleMergeRequestCleanupSchedulesBackfill, :sidekiq, schema: 20201023114628 do
+ let(:merge_requests) { table(:merge_requests) }
+ let(:cleanup_schedules) { table(:merge_request_cleanup_schedules) }
+
+ let(:namespace) { table(:namespaces).create!(name: 'name', path: 'path') }
+ let(:project) { table(:projects).create!(namespace_id: namespace.id) }
+
+ describe '#up' do
+ let!(:open_mr) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master') }
+
+ let!(:closed_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) }
+ let!(:closed_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 2) }
+
+ let!(:merged_mr_1) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3) }
+ let!(:merged_mr_2) { merge_requests.create!(target_project_id: project.id, source_branch: 'master', target_branch: 'master', state_id: 3) }
+
+ before do
+ stub_const("#{described_class}::BATCH_SIZE", 2)
+ end
+
+ it 'schdules BackfillMergeRequestCleanupSchedules background jobs' do
+ Sidekiq::Testing.fake! do
+ migrate!
+
+ aggregate_failures do
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(2.minutes, closed_mr_1.id, closed_mr_2.id)
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(4.minutes, merged_mr_1.id, merged_mr_2.id)
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb b/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb
new file mode 100644
index 00000000000..edae7330b1e
--- /dev/null
+++ b/spec/migrations/schedule_populate_has_vulnerabilities_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SchedulePopulateHasVulnerabilities do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:vulnerability_base_params) { { title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, author_id: user.id } }
+ let!(:project_1) { projects.create!(namespace_id: namespace.id, name: 'foo_1') }
+ let!(:project_2) { projects.create!(namespace_id: namespace.id, name: 'foo_2') }
+ let!(:project_3) { projects.create!(namespace_id: namespace.id, name: 'foo_3') }
+
+ around do |example|
+ freeze_time { Sidekiq::Testing.fake! { example.run } }
+ end
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+
+ vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_1.id))
+ vulnerabilities.create!(vulnerability_base_params.merge(project_id: project_3.id))
+ end
+
+ it 'schedules the background jobs', :aggregate_failures do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to be(2)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(2.minutes, project_1.id)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(4.minutes, project_3.id)
+ end
+end
diff --git a/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb b/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb
new file mode 100644
index 00000000000..e5934f2171f
--- /dev/null
+++ b/spec/migrations/schedule_populate_missing_dismissal_information_for_vulnerabilities_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!
+
+RSpec.describe SchedulePopulateMissingDismissalInformationForVulnerabilities do
+ let(:users) { table(:users) }
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:vulnerabilities) { table(:vulnerabilities) }
+ let(:user) { users.create!(name: 'test', email: 'test@example.com', projects_limit: 5) }
+ let(:namespace) { namespaces.create!(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create!(namespace_id: namespace.id, name: 'foo') }
+
+ let!(:vulnerability_1) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+ let!(:vulnerability_2) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_at: Time.now) }
+ let!(:vulnerability_3) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_by_id: user.id) }
+ let!(:vulnerability_4) { vulnerabilities.create!(title: 'title', state: 2, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id, dismissed_at: Time.now, dismissed_by_id: user.id) }
+ let!(:vulnerability_5) { vulnerabilities.create!(title: 'title', state: 1, severity: 0, confidence: 5, report_type: 2, project_id: project.id, author_id: user.id) }
+
+ around do |example|
+ freeze_time { Sidekiq::Testing.fake! { example.run } }
+ end
+
+ before do
+ stub_const("#{described_class.name}::BATCH_SIZE", 1)
+ end
+
+ it 'schedules the background jobs', :aggregate_failures do
+ migrate!
+
+ expect(BackgroundMigrationWorker.jobs.size).to be(3)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(3.minutes, vulnerability_1.id)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(6.minutes, vulnerability_2.id)
+ expect(described_class::MIGRATION_CLASS).to be_scheduled_delayed_migration(9.minutes, vulnerability_3.id)
+ end
+end
diff --git a/spec/migrations/seed_merge_trains_enabled_spec.rb b/spec/migrations/seed_merge_trains_enabled_spec.rb
new file mode 100644
index 00000000000..2abb064a111
--- /dev/null
+++ b/spec/migrations/seed_merge_trains_enabled_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'migrate', '20201026200736_seed_merge_trains_enabled.rb')
+
+RSpec.describe SeedMergeTrainsEnabled do
+ describe 'migrate' do
+ let(:project_ci_cd_settings) { table(:project_ci_cd_settings) }
+ let(:projects) { table(:projects) }
+ let(:namespaces) { table(:namespaces) }
+
+ context 'when on Gitlab.com' do
+ before do
+ namespace = namespaces.create!(name: 'hello', path: 'hello/')
+ project1 = projects.create!(namespace_id: namespace.id)
+ project2 = projects.create!(namespace_id: namespace.id)
+ project_ci_cd_settings.create!(project_id: project1.id, merge_pipelines_enabled: true)
+ project_ci_cd_settings.create!(project_id: project2.id, merge_pipelines_enabled: false)
+ end
+
+ it 'updates merge_trains_enabled to true for where merge_pipelines_enabled is true' do
+ migrate!
+
+ expect(project_ci_cd_settings.where(merge_trains_enabled: true).count).to be(1)
+ end
+ end
+ end
+end
diff --git a/spec/migrations/update_historical_data_recorded_at_spec.rb b/spec/migrations/update_historical_data_recorded_at_spec.rb
new file mode 100644
index 00000000000..bccc711f339
--- /dev/null
+++ b/spec/migrations/update_historical_data_recorded_at_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+require Rails.root.join('db', 'migrate', '20201022094846_update_historical_data_recorded_at.rb')
+
+RSpec.describe UpdateHistoricalDataRecordedAt do
+ let(:historical_data_table) { table(:historical_data) }
+
+ it 'reversibly populates recorded_at from created_at or date' do
+ row1 = historical_data_table.create!(
+ date: Date.current - 1.day,
+ created_at: Time.current - 1.day
+ )
+
+ row2 = historical_data_table.create!(date: Date.current - 2.days)
+ row2.update!(created_at: nil)
+
+ reversible_migration do |migration|
+ migration.before -> {
+ expect(row1.reload.recorded_at).to eq(nil)
+ expect(row2.reload.recorded_at).to eq(nil)
+ }
+
+ migration.after -> {
+ expect(row1.reload.recorded_at).to eq(row1.created_at)
+ expect(row2.reload.recorded_at).to eq(row2.date.in_time_zone(Time.zone).change(hour: 12))
+ }
+ end
+ end
+end
diff --git a/spec/models/alert_management/http_integration_spec.rb b/spec/models/alert_management/http_integration_spec.rb
index 37d67dfe09a..a3e7b47c116 100644
--- a/spec/models/alert_management/http_integration_spec.rb
+++ b/spec/models/alert_management/http_integration_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe AlertManagement::HttpIntegration do
+ include ::Gitlab::Routing.url_helpers
+
let_it_be(:project) { create(:project) }
subject(:integration) { build(:alert_management_http_integration) }
@@ -15,19 +17,17 @@ RSpec.describe AlertManagement::HttpIntegration do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_length_of(:name).is_at_most(255) }
- it { is_expected.to validate_presence_of(:endpoint_identifier) }
- it { is_expected.to validate_length_of(:endpoint_identifier).is_at_most(255) }
context 'when active' do
# Using `create` instead of `build` the integration so `token` is set.
# Uniqueness spec saves integration with `validate: false` otherwise.
- subject { create(:alert_management_http_integration) }
+ subject { create(:alert_management_http_integration, :legacy) }
it { is_expected.to validate_uniqueness_of(:endpoint_identifier).scoped_to(:project_id, :active) }
end
context 'when inactive' do
- subject { create(:alert_management_http_integration, :inactive) }
+ subject { create(:alert_management_http_integration, :legacy, :inactive) }
it { is_expected.not_to validate_uniqueness_of(:endpoint_identifier).scoped_to(:project_id, :active) }
end
@@ -51,10 +51,6 @@ RSpec.describe AlertManagement::HttpIntegration do
context 'when unsaved' do
context 'when unassigned' do
- before do
- integration.valid?
- end
-
it_behaves_like 'valid token'
end
@@ -89,4 +85,75 @@ RSpec.describe AlertManagement::HttpIntegration do
end
end
end
+
+ describe '#endpoint_identifier' do
+ subject { integration.endpoint_identifier }
+
+ context 'when defined on initialize' do
+ let(:integration) { described_class.new }
+
+ it { is_expected.to match(/\A\h{16}\z/) }
+ end
+
+ context 'when included in initialization args' do
+ let(:integration) { described_class.new(endpoint_identifier: 'legacy') }
+
+ it { is_expected.to eq('legacy') }
+ end
+
+ context 'when reassigning' do
+ let(:integration) { create(:alert_management_http_integration) }
+ let!(:starting_identifier) { subject }
+
+ it 'does not allow reassignment' do
+ integration.endpoint_identifier = 'newValidId'
+ integration.save!
+
+ expect(integration.reload.endpoint_identifier).to eq(starting_identifier)
+ end
+ end
+ end
+
+ describe '#url' do
+ subject { integration.url }
+
+ it do
+ is_expected.to eq(
+ project_alert_http_integration_url(
+ integration.project,
+ 'datadog',
+ integration.endpoint_identifier,
+ format: :json
+ )
+ )
+ end
+
+ context 'when name is not defined' do
+ let(:integration) { described_class.new(project: project) }
+
+ it do
+ is_expected.to eq(
+ project_alert_http_integration_url(
+ integration.project,
+ 'http-endpoint',
+ integration.endpoint_identifier,
+ format: :json
+ )
+ )
+ end
+ end
+
+ context 'for a legacy integration' do
+ let(:integration) { build(:alert_management_http_integration, :legacy) }
+
+ it do
+ is_expected.to eq(
+ project_alerts_notify_url(
+ integration.project,
+ format: :json
+ )
+ )
+ end
+ end
+ end
end
diff --git a/spec/models/analytics/cycle_analytics/project_stage_spec.rb b/spec/models/analytics/cycle_analytics/project_stage_spec.rb
index 4675f037957..fce31af619c 100644
--- a/spec/models/analytics/cycle_analytics/project_stage_spec.rb
+++ b/spec/models/analytics/cycle_analytics/project_stage_spec.rb
@@ -16,7 +16,7 @@ RSpec.describe Analytics::CycleAnalytics::ProjectStage do
end
end
- it_behaves_like 'cycle analytics stage' do
+ it_behaves_like 'value stream analytics stage' do
let(:parent) { build(:project) }
let(:parent_name) { :project }
end
diff --git a/spec/models/analytics/devops_adoption/segment_selection_spec.rb b/spec/models/analytics/devops_adoption/segment_selection_spec.rb
new file mode 100644
index 00000000000..5866cbaa48e
--- /dev/null
+++ b/spec/models/analytics/devops_adoption/segment_selection_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Analytics::DevopsAdoption::SegmentSelection, type: :model do
+ subject { build(:devops_adoption_segment_selection, :project) }
+
+ describe 'validation' do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project) }
+
+ it { is_expected.to validate_presence_of(:segment) }
+
+ context do
+ subject { create(:devops_adoption_segment_selection, :project, project: project) }
+
+ it { is_expected.to validate_uniqueness_of(:project_id).scoped_to(:segment_id) }
+ end
+
+ context do
+ subject { create(:devops_adoption_segment_selection, :group, group: group) }
+
+ it { is_expected.to validate_uniqueness_of(:group_id).scoped_to(:segment_id) }
+ end
+
+ it 'project is required' do
+ selection = build(:devops_adoption_segment_selection, project: nil, group: nil)
+
+ selection.validate
+
+ expect(selection.errors).to have_key(:project)
+ end
+
+ it 'project is not required when a group is given' do
+ selection = build(:devops_adoption_segment_selection, :group, group: group)
+
+ expect(selection).to be_valid
+ end
+
+ it 'does not allow group to be set when project is present' do
+ selection = build(:devops_adoption_segment_selection)
+
+ selection.group = group
+ selection.project = project
+
+ selection.validate
+
+ expect(selection.errors[:group]).to eq([s_('DevopsAdoptionSegmentSelection|The selection cannot be configured for a project and for a group at the same time')])
+ end
+
+ context 'limit the number of segment selections' do
+ let_it_be(:segment) { create(:devops_adoption_segment) }
+
+ subject { build(:devops_adoption_segment_selection, segment: segment, project: project) }
+
+ before do
+ create(:devops_adoption_segment_selection, :project, segment: segment)
+
+ stub_const("#{described_class}::ALLOWED_SELECTIONS_PER_SEGMENT", 1)
+ end
+
+ it 'shows validation error' do
+ subject.validate
+
+ expect(subject.errors[:segment]).to eq([s_('DevopsAdoptionSegment|The maximum number of selections has been reached')])
+ end
+ end
+ end
+end
diff --git a/spec/models/analytics/instance_statistics/measurement_spec.rb b/spec/models/analytics/instance_statistics/measurement_spec.rb
index 379272cfcb9..dbb16c5ffbe 100644
--- a/spec/models/analytics/instance_statistics/measurement_spec.rb
+++ b/spec/models/analytics/instance_statistics/measurement_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
describe 'identifiers enum' do
it 'maps to the correct values' do
- expect(described_class.identifiers).to eq({
+ identifiers = {
projects: 1,
users: 2,
issues: 3,
@@ -24,8 +24,11 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
pipelines_succeeded: 7,
pipelines_failed: 8,
pipelines_canceled: 9,
- pipelines_skipped: 10
- }.with_indifferent_access)
+ pipelines_skipped: 10,
+ billable_users: 11
+ }
+
+ expect(described_class.identifiers).to eq(identifiers.with_indifferent_access)
end
end
@@ -45,29 +48,71 @@ RSpec.describe Analytics::InstanceStatistics::Measurement, type: :model do
it { is_expected.to match_array([measurement_1, measurement_2]) }
end
- end
- describe '#measurement_identifier_values' do
- subject { described_class.measurement_identifier_values.count }
+ describe '.recorded_after' do
+ subject { described_class.recorded_after(8.days.ago) }
- context 'when the `store_ci_pipeline_counts_by_status` feature flag is off' do
- let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size - Analytics::InstanceStatistics::Measurement::EXPERIMENTAL_IDENTIFIERS.size }
+ it { is_expected.to match_array([measurement_2, measurement_3]) }
- before do
- stub_feature_flags(store_ci_pipeline_counts_by_status: false)
+ context 'when nil is given' do
+ subject { described_class.recorded_after(nil) }
+
+ it 'does not apply filtering' do
+ expect(subject).to match_array([measurement_1, measurement_2, measurement_3])
+ end
end
+ end
+
+ describe '.recorded_before' do
+ subject { described_class.recorded_before(4.days.ago) }
- it { is_expected.to eq(expected_count) }
+ it { is_expected.to match_array([measurement_1, measurement_3]) }
+
+ context 'when nil is given' do
+ subject { described_class.recorded_after(nil) }
+
+ it 'does not apply filtering' do
+ expect(subject).to match_array([measurement_1, measurement_2, measurement_3])
+ end
+ end
end
+ end
+
+ describe '.identifier_query_mapping' do
+ subject { described_class.identifier_query_mapping }
+
+ it { is_expected.to be_a Hash }
+ end
+
+ describe '.identifier_min_max_queries' do
+ subject { described_class.identifier_min_max_queries }
+
+ it { is_expected.to be_a Hash }
+ end
+
+ describe '.measurement_identifier_values' do
+ let(:expected_count) { described_class.identifiers.size }
+
+ subject { described_class.measurement_identifier_values.count }
+
+ it { is_expected.to eq(expected_count) }
+ end
- context 'when the `store_ci_pipeline_counts_by_status` feature flag is on' do
- let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size }
+ describe '.find_latest_or_fallback' do
+ subject(:count) { described_class.find_latest_or_fallback(:pipelines_skipped).count }
- before do
- stub_feature_flags(store_ci_pipeline_counts_by_status: true)
+ context 'with instance statistics' do
+ let!(:measurement) { create(:instance_statistics_measurement, :pipelines_skipped_count) }
+
+ it 'returns the latest stored measurement' do
+ expect(count).to eq measurement.count
end
+ end
- it { is_expected.to eq(expected_count) }
+ context 'without instance statistics' do
+ it 'returns the realtime query of the measurement' do
+ expect(count).to eq 0
+ end
end
end
end
diff --git a/spec/models/application_record_spec.rb b/spec/models/application_record_spec.rb
index d080b298e2f..6a0f2290b4c 100644
--- a/spec/models/application_record_spec.rb
+++ b/spec/models/application_record_spec.rb
@@ -67,7 +67,8 @@ RSpec.describe ApplicationRecord do
end
it 'raises a validation error if the record was not persisted' do
- expect { Suggestion.find_or_create_by!(note: nil) }.to raise_error(ActiveRecord::RecordInvalid)
+ expect { Suggestion.safe_find_or_create_by!(note: nil) }
+ .to raise_error(ActiveRecord::RecordInvalid)
end
it 'passes a block to find_or_create_by' do
@@ -75,6 +76,14 @@ RSpec.describe ApplicationRecord do
Suggestion.safe_find_or_create_by!(suggestion_attributes, &block)
end.to yield_with_args(an_object_having_attributes(suggestion_attributes))
end
+
+ it 'raises a record not found error in case of attributes mismatch' do
+ suggestion = Suggestion.safe_find_or_create_by!(suggestion_attributes)
+ attributes = suggestion_attributes.merge(outdated: !suggestion.outdated)
+
+ expect { Suggestion.safe_find_or_create_by!(attributes) }
+ .to raise_error(ActiveRecord::RecordNotFound)
+ end
end
end
diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb
index fb702d10a42..efe62a1d086 100644
--- a/spec/models/application_setting_spec.rb
+++ b/spec/models/application_setting_spec.rb
@@ -72,6 +72,7 @@ RSpec.describe ApplicationSetting do
it { is_expected.not_to allow_value(nil).for(:push_event_activities_limit) }
it { is_expected.to validate_numericality_of(:container_registry_delete_tags_service_timeout).only_integer.is_greater_than_or_equal_to(0) }
+ it { is_expected.to validate_numericality_of(:container_registry_expiration_policies_worker_capacity).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_numericality_of(:snippet_size_limit).only_integer.is_greater_than(0) }
it { is_expected.to validate_numericality_of(:wiki_page_max_content_bytes).only_integer.is_greater_than_or_equal_to(1024) }
@@ -647,6 +648,37 @@ RSpec.describe ApplicationSetting do
end
end
end
+
+ describe '#ci_jwt_signing_key' do
+ it { is_expected.not_to allow_value('').for(:ci_jwt_signing_key) }
+ it { is_expected.not_to allow_value('invalid RSA key').for(:ci_jwt_signing_key) }
+ it { is_expected.to allow_value(nil).for(:ci_jwt_signing_key) }
+ it { is_expected.to allow_value(OpenSSL::PKey::RSA.new(1024).to_pem).for(:ci_jwt_signing_key) }
+
+ it 'is encrypted' do
+ subject.ci_jwt_signing_key = OpenSSL::PKey::RSA.new(1024).to_pem
+
+ aggregate_failures do
+ expect(subject.encrypted_ci_jwt_signing_key).to be_present
+ expect(subject.encrypted_ci_jwt_signing_key_iv).to be_present
+ expect(subject.encrypted_ci_jwt_signing_key).not_to eq(subject.ci_jwt_signing_key)
+ end
+ end
+ end
+
+ describe '#cloud_license_auth_token' do
+ it { is_expected.to allow_value(nil).for(:cloud_license_auth_token) }
+
+ it 'is encrypted' do
+ subject.cloud_license_auth_token = 'token-from-customers-dot'
+
+ aggregate_failures do
+ expect(subject.encrypted_cloud_license_auth_token).to be_present
+ expect(subject.encrypted_cloud_license_auth_token_iv).to be_present
+ expect(subject.encrypted_cloud_license_auth_token).not_to eq(subject.cloud_license_auth_token)
+ end
+ end
+ end
end
context 'static objects external storage' do
diff --git a/spec/models/authentication_event_spec.rb b/spec/models/authentication_event_spec.rb
index 483d45c08be..83598fa6765 100644
--- a/spec/models/authentication_event_spec.rb
+++ b/spec/models/authentication_event_spec.rb
@@ -37,15 +37,11 @@ RSpec.describe AuthenticationEvent do
describe '.providers' do
before do
- create(:authentication_event, provider: :ldapmain)
- create(:authentication_event, provider: :google_oauth2)
- create(:authentication_event, provider: :standard)
- create(:authentication_event, provider: :standard)
- create(:authentication_event, provider: :standard)
+ allow(Devise).to receive(:omniauth_providers).and_return(%w(ldapmain google_oauth2))
end
it 'returns an array of distinct providers' do
- expect(described_class.providers).to match_array %w(ldapmain google_oauth2 standard)
+ expect(described_class.providers).to match_array %w(ldapmain google_oauth2 standard two-factor two-factor-via-u2f-device two-factor-via-webauthn-device)
end
end
end
diff --git a/spec/models/broadcast_message_spec.rb b/spec/models/broadcast_message_spec.rb
index fc463c6af52..c4d17905637 100644
--- a/spec/models/broadcast_message_spec.rb
+++ b/spec/models/broadcast_message_spec.rb
@@ -161,6 +161,12 @@ RSpec.describe BroadcastMessage do
expect(subject.call('/group/issues/test').length).to eq(1)
end
+
+ it "does not return message if the target path is set but no current path is provided" do
+ create(:broadcast_message, target_path: "*/issues/*", broadcast_type: broadcast_type)
+
+ expect(subject.call.length).to eq(0)
+ end
end
describe '.current', :use_clean_rails_memory_store_caching do
diff --git a/spec/models/bulk_imports/tracker_spec.rb b/spec/models/bulk_imports/tracker_spec.rb
new file mode 100644
index 00000000000..8eb5a6c27dd
--- /dev/null
+++ b/spec/models/bulk_imports/tracker_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImports::Tracker, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:entity).required }
+ end
+
+ describe 'validations' do
+ before do
+ create(:bulk_import_tracker)
+ end
+
+ it { is_expected.to validate_presence_of(:relation) }
+ it { is_expected.to validate_uniqueness_of(:relation).scoped_to(:bulk_import_entity_id) }
+
+ context 'when has_next_page is true' do
+ it "validates presence of `next_page`" do
+ tracker = build(:bulk_import_tracker, has_next_page: true)
+
+ expect(tracker).not_to be_valid
+ expect(tracker.errors).to include(:next_page)
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index c464e176c17..51e82061d97 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -55,6 +55,17 @@ RSpec.describe Ci::Bridge do
expect(bridge.scoped_variables_hash.keys).to include(*variables)
end
+
+ context 'when bridge has dependency which has dotenv variable' do
+ let(:test) { create(:ci_build, pipeline: pipeline, stage_idx: 0) }
+ let(:bridge) { create(:ci_bridge, pipeline: pipeline, stage_idx: 1, options: { dependencies: [test.name] }) }
+
+ let!(:job_variable) { create(:ci_job_variable, :dotenv_source, job: test) }
+
+ it 'includes inherited variable' do
+ expect(bridge.scoped_variables_hash).to include(job_variable.key => job_variable.value)
+ end
+ end
end
describe 'state machine transitions' do
@@ -357,4 +368,53 @@ RSpec.describe Ci::Bridge do
it { is_expected.to be_falsey }
end
end
+
+ describe '#dependency_variables' do
+ subject { bridge.dependency_variables }
+
+ shared_context 'when ci_bridge_dependency_variables is disabled' do
+ before do
+ stub_feature_flags(ci_bridge_dependency_variables: false)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when downloading from previous stages' do
+ let!(:prepare1) { create(:ci_build, name: 'prepare1', pipeline: pipeline, stage_idx: 0) }
+ let!(:bridge) { create(:ci_bridge, pipeline: pipeline, stage_idx: 1) }
+
+ let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) }
+ let!(:job_variable_2) { create(:ci_job_variable, job: prepare1) }
+
+ it 'inherits only dependent variables' do
+ expect(subject.to_hash).to eq(job_variable_1.key => job_variable_1.value)
+ end
+
+ it_behaves_like 'when ci_bridge_dependency_variables is disabled'
+ 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!(:bridge) do
+ create(:ci_bridge, pipeline: pipeline,
+ stage_idx: 1,
+ scheduling_type: 'dag',
+ needs_attributes: [{ name: 'prepare1', artifacts: true },
+ { name: 'prepare2', artifacts: false }])
+ end
+
+ let!(:job_variable_1) { create(:ci_job_variable, :dotenv_source, job: prepare1) }
+ 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
+
+ it_behaves_like 'when ci_bridge_dependency_variables is disabled'
+ end
+ end
end
diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb
index f1d51324bbf..5ff9b4dd493 100644
--- a/spec/models/ci/build_spec.rb
+++ b/spec/models/ci/build_spec.rb
@@ -2464,7 +2464,7 @@ RSpec.describe Ci::Build do
end
before do
- allow(Gitlab::Ci::Jwt).to receive(:for_build).with(build).and_return('ci.job.jwt')
+ allow(Gitlab::Ci::Jwt).to receive(:for_build).and_return('ci.job.jwt')
build.set_token('my-token')
build.yaml_variables = []
end
@@ -2482,12 +2482,17 @@ RSpec.describe Ci::Build do
end
context 'when CI_JOB_JWT generation fails' do
- it 'CI_JOB_JWT is not included' do
- expect(Gitlab::Ci::Jwt).to receive(:for_build).and_raise(OpenSSL::PKey::RSAError, 'Neither PUB key nor PRIV key: not enough data')
- expect(Gitlab::ErrorTracking).to receive(:track_exception)
-
- expect { subject }.not_to raise_error
- expect(subject.pluck(:key)).not_to include('CI_JOB_JWT')
+ [
+ OpenSSL::PKey::RSAError,
+ Gitlab::Ci::Jwt::NoSigningKeyError
+ ].each do |reason_to_fail|
+ it 'CI_JOB_JWT is not included' do
+ expect(Gitlab::Ci::Jwt).to receive(:for_build).and_raise(reason_to_fail)
+ expect(Gitlab::ErrorTracking).to receive(:track_exception)
+
+ expect { subject }.not_to raise_error
+ expect(subject.pluck(:key)).not_to include('CI_JOB_JWT')
+ end
end
end
diff --git a/spec/models/ci/build_trace_chunk_spec.rb b/spec/models/ci/build_trace_chunk_spec.rb
index 871f279db08..dce7b1d30ca 100644
--- a/spec/models/ci/build_trace_chunk_spec.rb
+++ b/spec/models/ci/build_trace_chunk_spec.rb
@@ -100,15 +100,15 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
subject { described_class.all_stores }
it 'returns a correctly ordered array' do
- is_expected.to eq(%w[redis database fog])
+ is_expected.to eq(%i[redis database fog])
end
it 'returns redis store as the lowest precedence' do
- expect(subject.first).to eq('redis')
+ expect(subject.first).to eq(:redis)
end
it 'returns fog store as the highest precedence' do
- expect(subject.last).to eq('fog')
+ expect(subject.last).to eq(:fog)
end
end
@@ -135,32 +135,40 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
context 'when data_store is fog' do
let(:data_store) { :fog }
- context 'when legacy Fog is enabled' do
- before do
- stub_feature_flags(ci_trace_new_fog_store: false)
- build_trace_chunk.send(:unsafe_set_data!, +'Sample data in fog')
- end
+ before do
+ build_trace_chunk.send(:unsafe_set_data!, +'Sample data in fog')
+ end
- it { is_expected.to eq('Sample data in fog') }
+ it { is_expected.to eq('Sample data in fog') }
- it 'returns a LegacyFog store' do
- expect(described_class.get_store_class(data_store)).to be_a(Ci::BuildTraceChunks::LegacyFog)
- end
+ it 'returns a new Fog store' do
+ expect(described_class.get_store_class(data_store)).to be_a(Ci::BuildTraceChunks::Fog)
end
+ end
+ end
- context 'when new Fog is enabled' do
- before do
- stub_feature_flags(ci_trace_new_fog_store: true)
- build_trace_chunk.send(:unsafe_set_data!, +'Sample data in fog')
- end
+ describe '#get_store_class' do
+ using RSpec::Parameterized::TableSyntax
- it { is_expected.to eq('Sample data in fog') }
+ where(:data_store, :expected_store) do
+ :redis | Ci::BuildTraceChunks::Redis
+ :database | Ci::BuildTraceChunks::Database
+ :fog | Ci::BuildTraceChunks::Fog
+ end
- it 'returns a new Fog store' do
- expect(described_class.get_store_class(data_store)).to be_a(Ci::BuildTraceChunks::Fog)
+ with_them do
+ context "with store" do
+ it 'returns an instance of the right class' do
+ expect(expected_store).to receive(:new).twice.and_call_original
+ expect(described_class.get_store_class(data_store.to_s)).to be_a(expected_store)
+ expect(described_class.get_store_class(data_store.to_sym)).to be_a(expected_store)
end
end
end
+
+ it 'raises an error' do
+ expect { described_class.get_store_class('unknown') }.to raise_error('Unknown store type: unknown')
+ end
end
describe '#append' do
@@ -614,23 +622,19 @@ RSpec.describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
context 'when the chunk is being locked by a different worker' do
let(:metrics) { spy('metrics') }
- it 'does not raise an exception' do
- lock_chunk do
- expect { build_trace_chunk.persist_data! }.not_to raise_error
- end
- end
-
it 'increments stalled chunk trace metric' do
allow(build_trace_chunk)
.to receive(:metrics)
.and_return(metrics)
- lock_chunk { build_trace_chunk.persist_data! }
+ expect do
+ subject
- expect(metrics)
- .to have_received(:increment_trace_operation)
- .with(operation: :stalled)
- .once
+ expect(metrics)
+ .to have_received(:increment_trace_operation)
+ .with(operation: :stalled)
+ .once
+ end.to raise_error(described_class::FailedToPersistDataError)
end
def lock_chunk(&block)
diff --git a/spec/models/ci/build_trace_chunks/legacy_fog_spec.rb b/spec/models/ci/build_trace_chunks/legacy_fog_spec.rb
deleted file mode 100644
index ca4b414b992..00000000000
--- a/spec/models/ci/build_trace_chunks/legacy_fog_spec.rb
+++ /dev/null
@@ -1,164 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Ci::BuildTraceChunks::LegacyFog do
- let(:data_store) { described_class.new }
-
- before do
- stub_artifacts_object_storage
- end
-
- describe '#available?' do
- subject { data_store.available? }
-
- context 'when object storage is enabled' do
- it { is_expected.to be_truthy }
- end
-
- context 'when object storage is disabled' do
- before do
- stub_artifacts_object_storage(enabled: false)
- end
-
- it { is_expected.to be_falsy }
- end
- end
-
- describe '#data' do
- subject { data_store.data(model) }
-
- context 'when data exists' do
- let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') }
-
- it 'returns the data' do
- is_expected.to eq('sample data in fog')
- end
- end
-
- context 'when data does not exist' do
- let(:model) { create(:ci_build_trace_chunk, :fog_without_data) }
-
- it 'returns nil' do
- expect(data_store.data(model)).to be_nil
- end
- end
- end
-
- describe '#set_data' do
- let(:new_data) { 'abc123' }
-
- context 'when data exists' do
- let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') }
-
- it 'overwrites data' do
- expect(data_store.data(model)).to eq('sample data in fog')
-
- data_store.set_data(model, new_data)
-
- expect(data_store.data(model)).to eq new_data
- end
- end
-
- context 'when data does not exist' do
- let(:model) { create(:ci_build_trace_chunk, :fog_without_data) }
-
- it 'sets new data' do
- expect(data_store.data(model)).to be_nil
-
- data_store.set_data(model, new_data)
-
- expect(data_store.data(model)).to eq new_data
- end
- end
- end
-
- describe '#delete_data' do
- subject { data_store.delete_data(model) }
-
- context 'when data exists' do
- let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'sample data in fog') }
-
- it 'deletes data' do
- expect(data_store.data(model)).to eq('sample data in fog')
-
- subject
-
- expect(data_store.data(model)).to be_nil
- end
- end
-
- context 'when data does not exist' do
- let(:model) { create(:ci_build_trace_chunk, :fog_without_data) }
-
- it 'does nothing' do
- expect(data_store.data(model)).to be_nil
-
- subject
-
- expect(data_store.data(model)).to be_nil
- end
- end
- end
-
- describe '#size' do
- context 'when data exists' do
- let(:model) { create(:ci_build_trace_chunk, :fog_with_data, initial_data: 'üabcd') }
-
- it 'returns data bytesize correctly' do
- expect(data_store.size(model)).to eq 6
- end
- end
-
- context 'when data does not exist' do
- let(:model) { create(:ci_build_trace_chunk, :fog_without_data) }
-
- it 'returns zero' do
- expect(data_store.size(model)).to be_zero
- end
- end
- end
-
- describe '#keys' do
- subject { data_store.keys(relation) }
-
- let(:build) { create(:ci_build) }
- let(:relation) { build.trace_chunks }
-
- before do
- create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 0, build: build)
- create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 1, build: build)
- end
-
- it 'returns keys' do
- is_expected.to eq([[build.id, 0], [build.id, 1]])
- end
- end
-
- describe '#delete_keys' do
- subject { data_store.delete_keys(keys) }
-
- let(:build) { create(:ci_build) }
- let(:relation) { build.trace_chunks }
- let(:keys) { data_store.keys(relation) }
-
- before do
- create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 0, build: build)
- create(:ci_build_trace_chunk, :fog_with_data, chunk_index: 1, build: build)
- end
-
- it 'deletes multiple data' do
- ::Fog::Storage.new(JobArtifactUploader.object_store_credentials).tap do |connection|
- expect(connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/0.log")[:body]).to be_present
- expect(connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/1.log")[:body]).to be_present
- end
-
- subject
-
- ::Fog::Storage.new(JobArtifactUploader.object_store_credentials).tap do |connection|
- expect { connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/0.log")[:body] }.to raise_error(Excon::Error::NotFound)
- expect { connection.get_object('artifacts', "tmp/builds/#{build.id}/chunks/1.log")[:body] }.to raise_error(Excon::Error::NotFound)
- end
- 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
index 326366666cb..f16396d62c9 100644
--- a/spec/models/ci/daily_build_group_report_result_spec.rb
+++ b/spec/models/ci/daily_build_group_report_result_spec.rb
@@ -81,4 +81,81 @@ RSpec.describe Ci::DailyBuildGroupReportResult do
end
end
end
+
+ describe 'scopes' do
+ let_it_be(:project) { create(:project) }
+ let(:recent_build_group_report_result) { create(:ci_daily_build_group_report_result, project: project) }
+ let(:old_build_group_report_result) do
+ create(:ci_daily_build_group_report_result, date: 1.week.ago, project: project)
+ end
+
+ describe '.by_projects' do
+ subject { described_class.by_projects([project.id]) }
+
+ it 'returns records by projects' do
+ expect(subject).to contain_exactly(recent_build_group_report_result, old_build_group_report_result)
+ end
+ end
+
+ describe '.with_coverage' do
+ subject { described_class.with_coverage }
+
+ it 'returns data with coverage' do
+ expect(subject).to contain_exactly(recent_build_group_report_result, old_build_group_report_result)
+ end
+ end
+
+ describe '.with_default_branch' do
+ subject(:coverages) { described_class.with_default_branch }
+
+ context 'when coverage for the default branch exist' do
+ let!(:recent_build_group_report_result) { create(:ci_daily_build_group_report_result, project: project) }
+ let!(:coverage_feature_branch) { create(:ci_daily_build_group_report_result, :on_feature_branch, project: project) }
+
+ it 'returns coverage with the default branch' do
+ expect(coverages).to contain_exactly(recent_build_group_report_result)
+ end
+ end
+
+ context 'when coverage for the default branch does not exist' do
+ it 'returns an empty collection' do
+ expect(coverages).to be_empty
+ end
+ end
+ end
+
+ describe '.by_date' do
+ subject(:coverages) { described_class.by_date(start_date) }
+
+ let!(:coverage_1) { create(:ci_daily_build_group_report_result, date: 1.week.ago) }
+
+ context 'when project has several coverage' do
+ let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 2.weeks.ago) }
+ let(:start_date) { 1.week.ago.to_date.to_s }
+
+ it 'returns the coverage from the start_date' do
+ expect(coverages).to contain_exactly(coverage_1)
+ end
+ end
+
+ context 'when start_date is over 90 days' do
+ let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
+ let!(:coverage_3) { create(:ci_daily_build_group_report_result, date: 91.days.ago) }
+ let(:start_date) { 1.year.ago.to_date.to_s }
+
+ it 'returns the coverage in the last 90 days' do
+ expect(coverages).to contain_exactly(coverage_1, coverage_2)
+ end
+ end
+
+ context 'when start_date is not a string' do
+ let!(:coverage_2) { create(:ci_daily_build_group_report_result, date: 90.days.ago) }
+ let(:start_date) { 1.week.ago }
+
+ it 'returns the coverage in the last 90 days' do
+ expect(coverages).to contain_exactly(coverage_1, coverage_2)
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 88d08f1ec45..1ca370dc950 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -625,7 +625,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
- describe "coverage" do
+ describe '#coverage' do
let(:project) { create(:project, build_coverage_regex: "/.*/") }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
@@ -1972,6 +1972,32 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
+ describe '.latest_running_for_ref' do
+ include_context 'with some outdated pipelines'
+
+ let!(:latest_running_pipeline) do
+ create_pipeline(:running, 'ref', 'D', project)
+ end
+
+ it 'returns the latest running pipeline' do
+ expect(described_class.latest_running_for_ref('ref'))
+ .to eq(latest_running_pipeline)
+ end
+ end
+
+ describe '.latest_failed_for_ref' do
+ include_context 'with some outdated pipelines'
+
+ let!(:latest_failed_pipeline) do
+ create_pipeline(:failed, 'ref', 'D', project)
+ end
+
+ it 'returns the latest failed pipeline' do
+ expect(described_class.latest_failed_for_ref('ref'))
+ .to eq(latest_failed_pipeline)
+ end
+ end
+
describe '.latest_successful_for_sha' do
include_context 'with some outdated pipelines'
diff --git a/spec/models/ci/test_case_failure_spec.rb b/spec/models/ci/test_case_failure_spec.rb
new file mode 100644
index 00000000000..34f89b663ed
--- /dev/null
+++ b/spec/models/ci/test_case_failure_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::TestCaseFailure do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:build) }
+ it { is_expected.to belong_to(:test_case) }
+ end
+
+ describe 'validations' do
+ subject { build(:ci_test_case_failure) }
+
+ it { is_expected.to validate_presence_of(:test_case) }
+ it { is_expected.to validate_presence_of(:build) }
+ it { is_expected.to validate_presence_of(:failed_at) }
+ end
+
+ describe '.recent_failures_count' do
+ let_it_be(:project) { create(:project) }
+
+ subject(:recent_failures) do
+ described_class.recent_failures_count(
+ project: project,
+ test_case_keys: test_case_keys
+ )
+ end
+
+ context 'when test case failures are within the date range and are for the test case keys' do
+ let(:tc_1) { create(:ci_test_case, project: project) }
+ let(:tc_2) { create(:ci_test_case, project: project) }
+ let(:test_case_keys) { [tc_1.key_hash, tc_2.key_hash] }
+
+ before do
+ create_list(:ci_test_case_failure, 3, test_case: tc_1, failed_at: 1.day.ago)
+ create_list(:ci_test_case_failure, 2, test_case: tc_2, failed_at: 3.days.ago)
+ end
+
+ it 'returns the number of failures for each test case key hash for the past 14 days by default' do
+ expect(recent_failures).to eq(
+ tc_1.key_hash => 3,
+ tc_2.key_hash => 2
+ )
+ end
+ end
+
+ context 'when test case failures are within the date range but are not for the test case keys' do
+ let(:tc) { create(:ci_test_case, project: project) }
+ let(:test_case_keys) { ['some-other-key-hash'] }
+
+ before do
+ create(:ci_test_case_failure, test_case: tc, failed_at: 1.day.ago)
+ end
+
+ it 'excludes them from the count' do
+ expect(recent_failures[tc.key_hash]).to be_nil
+ end
+ end
+
+ context 'when test case failures are not within the date range but are for the test case keys' do
+ let(:tc) { create(:ci_test_case, project: project) }
+ let(:test_case_keys) { [tc.key_hash] }
+
+ before do
+ create(:ci_test_case_failure, test_case: tc, failed_at: 15.days.ago)
+ end
+
+ it 'excludes them from the count' do
+ expect(recent_failures[tc.key_hash]).to be_nil
+ end
+ end
+ end
+end
diff --git a/spec/models/ci/test_case_spec.rb b/spec/models/ci/test_case_spec.rb
new file mode 100644
index 00000000000..45311e285a6
--- /dev/null
+++ b/spec/models/ci/test_case_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::TestCase do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:project) }
+ it { is_expected.to have_many(:test_case_failures) }
+ end
+
+ describe 'validations' do
+ subject { build(:ci_test_case) }
+
+ it { is_expected.to validate_presence_of(:project) }
+ it { is_expected.to validate_presence_of(:key_hash) }
+ end
+
+ describe '.find_or_create_by_batch' do
+ it 'finds or creates records for the given test case keys', :aggregate_failures do
+ project = create(:project)
+ existing_tc = create(:ci_test_case, project: project)
+ new_key = Digest::SHA256.hexdigest(SecureRandom.hex)
+ keys = [existing_tc.key_hash, new_key]
+
+ result = described_class.find_or_create_by_batch(project, keys)
+
+ expect(result.map(&:key_hash)).to match_array([existing_tc.key_hash, new_key])
+ expect(result).to all(be_persisted)
+ end
+ end
+end
diff --git a/spec/models/clusters/agent_token_spec.rb b/spec/models/clusters/agent_token_spec.rb
index ad9dd11b24e..9110fdeda52 100644
--- a/spec/models/clusters/agent_token_spec.rb
+++ b/spec/models/clusters/agent_token_spec.rb
@@ -14,5 +14,10 @@ RSpec.describe Clusters::AgentToken do
expect(agent_token.token).to be_present
end
+
+ it 'is at least 50 characters' do
+ agent_token = create(:cluster_agent_token)
+ expect(agent_token.token.length).to be >= 50
+ end
end
end
diff --git a/spec/models/clusters/applications/cert_manager_spec.rb b/spec/models/clusters/applications/cert_manager_spec.rb
index 7ca7f533a27..3044260a000 100644
--- a/spec/models/clusters/applications/cert_manager_spec.rb
+++ b/spec/models/clusters/applications/cert_manager_spec.rb
@@ -40,7 +40,7 @@ RSpec.describe Clusters::Applications::CertManager do
subject { cert_manager.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with cert_manager arguments' do
expect(subject.name).to eq('certmanager')
@@ -90,7 +90,7 @@ RSpec.describe Clusters::Applications::CertManager do
describe '#uninstall_command' do
subject { cert_manager.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
it 'is initialized with cert_manager arguments' do
expect(subject.name).to eq('certmanager')
diff --git a/spec/models/clusters/applications/crossplane_spec.rb b/spec/models/clusters/applications/crossplane_spec.rb
index a41c5f6586b..7082576028b 100644
--- a/spec/models/clusters/applications/crossplane_spec.rb
+++ b/spec/models/clusters/applications/crossplane_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe Clusters::Applications::Crossplane do
subject { crossplane.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with crossplane arguments' do
expect(subject.name).to eq('crossplane')
diff --git a/spec/models/clusters/applications/elastic_stack_spec.rb b/spec/models/clusters/applications/elastic_stack_spec.rb
index 62123ffa542..74cacd486b0 100644
--- a/spec/models/clusters/applications/elastic_stack_spec.rb
+++ b/spec/models/clusters/applications/elastic_stack_spec.rb
@@ -15,7 +15,7 @@ RSpec.describe Clusters::Applications::ElasticStack do
subject { elastic_stack.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with elastic stack arguments' do
expect(subject.name).to eq('elastic-stack')
@@ -57,7 +57,7 @@ RSpec.describe Clusters::Applications::ElasticStack do
it 'includes a preinstall script' do
expect(subject.preinstall).not_to be_empty
- expect(subject.preinstall.first).to include("delete")
+ expect(subject.preinstall.first).to include("helm uninstall")
end
end
@@ -69,7 +69,7 @@ RSpec.describe Clusters::Applications::ElasticStack do
it 'includes a preinstall script' do
expect(subject.preinstall).not_to be_empty
- expect(subject.preinstall.first).to include("delete")
+ expect(subject.preinstall.first).to include("helm uninstall")
end
end
@@ -123,7 +123,7 @@ RSpec.describe Clusters::Applications::ElasticStack do
subject { elastic_stack.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
it 'is initialized with elastic stack arguments' do
expect(subject.name).to eq('elastic-stack')
diff --git a/spec/models/clusters/applications/fluentd_spec.rb b/spec/models/clusters/applications/fluentd_spec.rb
index 3bda3e99ec1..ccdf6b0e40d 100644
--- a/spec/models/clusters/applications/fluentd_spec.rb
+++ b/spec/models/clusters/applications/fluentd_spec.rb
@@ -21,7 +21,7 @@ RSpec.describe Clusters::Applications::Fluentd do
describe '#install_command' do
subject { fluentd.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with fluentd arguments' do
expect(subject.name).to eq('fluentd')
diff --git a/spec/models/clusters/applications/helm_spec.rb b/spec/models/clusters/applications/helm_spec.rb
index 6d2ecaa6d47..ad1ebd4966a 100644
--- a/spec/models/clusters/applications/helm_spec.rb
+++ b/spec/models/clusters/applications/helm_spec.rb
@@ -56,7 +56,7 @@ RSpec.describe Clusters::Applications::Helm do
subject { application.issue_client_cert }
it 'returns a new cert' do
- is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::Certificate)
+ is_expected.to be_kind_of(Gitlab::Kubernetes::Helm::V2::Certificate)
expect(subject.cert_string).not_to eq(application.ca_cert)
expect(subject.key_string).not_to eq(application.ca_key)
end
@@ -67,7 +67,7 @@ RSpec.describe Clusters::Applications::Helm do
subject { helm.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InitCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::InitCommand) }
it 'is initialized with 1 arguments' do
expect(subject.name).to eq('helm')
@@ -104,7 +104,7 @@ RSpec.describe Clusters::Applications::Helm do
subject { helm.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::ResetCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V2::ResetCommand) }
it 'has name' do
expect(subject.name).to eq('helm')
diff --git a/spec/models/clusters/applications/ingress_spec.rb b/spec/models/clusters/applications/ingress_spec.rb
index 196d57aff7b..1bc1a4343aa 100644
--- a/spec/models/clusters/applications/ingress_spec.rb
+++ b/spec/models/clusters/applications/ingress_spec.rb
@@ -131,7 +131,7 @@ RSpec.describe Clusters::Applications::Ingress do
describe '#install_command' do
subject { ingress.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with ingress arguments' do
expect(subject.name).to eq('ingress')
diff --git a/spec/models/clusters/applications/jupyter_spec.rb b/spec/models/clusters/applications/jupyter_spec.rb
index 3cf24f1a9ef..e7de2d24334 100644
--- a/spec/models/clusters/applications/jupyter_spec.rb
+++ b/spec/models/clusters/applications/jupyter_spec.rb
@@ -52,7 +52,7 @@ RSpec.describe Clusters::Applications::Jupyter do
subject { jupyter.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with 4 arguments' do
expect(subject.name).to eq('jupyter')
diff --git a/spec/models/clusters/applications/knative_spec.rb b/spec/models/clusters/applications/knative_spec.rb
index b14161ce8e6..41b4ec86233 100644
--- a/spec/models/clusters/applications/knative_spec.rb
+++ b/spec/models/clusters/applications/knative_spec.rb
@@ -119,7 +119,7 @@ RSpec.describe Clusters::Applications::Knative do
shared_examples 'a command' do
it 'is an instance of Helm::InstallCommand' do
- expect(subject).to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand)
+ expect(subject).to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand)
end
it 'is initialized with knative arguments' do
@@ -171,7 +171,7 @@ RSpec.describe Clusters::Applications::Knative do
describe '#uninstall_command' do
subject { knative.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
it "removes knative deployed services before uninstallation" do
2.times do |i|
diff --git a/spec/models/clusters/applications/prometheus_spec.rb b/spec/models/clusters/applications/prometheus_spec.rb
index b450900bee6..032de6aa7c2 100644
--- a/spec/models/clusters/applications/prometheus_spec.rb
+++ b/spec/models/clusters/applications/prometheus_spec.rb
@@ -148,7 +148,7 @@ RSpec.describe Clusters::Applications::Prometheus do
subject { prometheus.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with 3 arguments' do
expect(subject.name).to eq('prometheus')
@@ -195,7 +195,7 @@ RSpec.describe Clusters::Applications::Prometheus do
subject { prometheus.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
it 'has the application name' do
expect(subject.name).to eq('prometheus')
@@ -236,7 +236,7 @@ RSpec.describe Clusters::Applications::Prometheus do
let(:prometheus) { build(:clusters_applications_prometheus) }
let(:values) { prometheus.values }
- it { is_expected.to be_an_instance_of(::Gitlab::Kubernetes::Helm::PatchCommand) }
+ it { is_expected.to be_an_instance_of(::Gitlab::Kubernetes::Helm::V3::PatchCommand) }
it 'is initialized with 3 arguments' do
expect(patch_command.name).to eq('prometheus')
diff --git a/spec/models/clusters/applications/runner_spec.rb b/spec/models/clusters/applications/runner_spec.rb
index ef916c73e0b..43e2eab3b9d 100644
--- a/spec/models/clusters/applications/runner_spec.rb
+++ b/spec/models/clusters/applications/runner_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Clusters::Applications::Runner do
subject { gitlab_runner.install_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::InstallCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::InstallCommand) }
it 'is initialized with 4 arguments' do
expect(subject.name).to eq('runner')
diff --git a/spec/models/clusters/cluster_spec.rb b/spec/models/clusters/cluster_spec.rb
index dd9b96f39ad..ed74a841044 100644
--- a/spec/models/clusters/cluster_spec.rb
+++ b/spec/models/clusters/cluster_spec.rb
@@ -540,6 +540,27 @@ RSpec.describe Clusters::Cluster, :use_clean_rails_memory_store_caching do
end
end
end
+
+ describe 'helm_major_version can only be 2 or 3' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:helm_major_version, :expect_valid) do
+ 2 | true
+ 3 | true
+ 4 | false
+ -1 | false
+ end
+
+ with_them do
+ let(:cluster) { build(:cluster, helm_major_version: helm_major_version) }
+
+ it { is_expected.to eq(expect_valid) }
+ end
+ end
+ end
+
+ it 'has default helm_major_version 3' do
+ expect(create(:cluster).helm_major_version).to eq(3)
end
describe '.ancestor_clusters_for_clusterable' do
diff --git a/spec/models/commit_status_spec.rb b/spec/models/commit_status_spec.rb
index 877188097fd..9824eb91bc7 100644
--- a/spec/models/commit_status_spec.rb
+++ b/spec/models/commit_status_spec.rb
@@ -493,104 +493,49 @@ RSpec.describe CommitStatus do
end
end
- context 'with the one_dimensional_matrix feature flag disabled' do
- describe '#group_name' do
- before do
- stub_feature_flags(one_dimensional_matrix: false)
- end
-
- let(:commit_status) do
- build(:commit_status, pipeline: pipeline, stage: 'test')
- end
-
- subject { commit_status.group_name }
-
- tests = {
- 'rspec:windows' => 'rspec:windows',
- 'rspec:windows 0' => 'rspec:windows 0',
- 'rspec:windows 0 test' => 'rspec:windows 0 test',
- 'rspec:windows 0 1' => 'rspec:windows',
- 'rspec:windows 0 1 name' => 'rspec:windows name',
- 'rspec:windows 0/1' => 'rspec:windows',
- 'rspec:windows 0/1 name' => 'rspec:windows name',
- 'rspec:windows 0:1' => 'rspec:windows',
- 'rspec:windows 0:1 name' => 'rspec:windows name',
- 'rspec:windows 10000 20000' => 'rspec:windows',
- 'rspec:windows 0 : / 1' => 'rspec:windows',
- 'rspec:windows 0 : / 1 name' => 'rspec:windows name',
- '0 1 name ruby' => 'name ruby',
- '0 :/ 1 name ruby' => 'name ruby',
- 'rspec: [aws]' => 'rspec: [aws]',
- 'rspec: [aws] 0/1' => 'rspec: [aws]',
- 'rspec: [aws, max memory]' => 'rspec',
- 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
- 'rspec: [inception: [something, other thing], value]' => 'rspec',
- 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
- 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
- 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
- 'rspec:windows: [0/1, name]' => 'rspec:windows',
- 'rspec:windows: [, ]' => 'rspec:windows',
- 'rspec:windows: [name]' => 'rspec:windows: [name]',
- 'rspec:windows: [name,other]' => 'rspec:windows: [name,other]'
- }
-
- tests.each do |name, group_name|
- it "'#{name}' puts in '#{group_name}'" do
- commit_status.name = name
-
- is_expected.to eq(group_name)
- end
- end
- end
- end
+ describe '#group_name' do
+ using RSpec::Parameterized::TableSyntax
- context 'with one_dimensional_matrix feature flag enabled' do
- describe '#group_name' do
- before do
- stub_feature_flags(one_dimensional_matrix: true)
- end
+ let(:commit_status) do
+ build(:commit_status, pipeline: pipeline, stage: 'test')
+ end
+
+ subject { commit_status.group_name }
+
+ where(:name, :group_name) do
+ 'rspec:windows' | 'rspec:windows'
+ 'rspec:windows 0' | 'rspec:windows 0'
+ 'rspec:windows 0 test' | 'rspec:windows 0 test'
+ 'rspec:windows 0 1' | 'rspec:windows'
+ 'rspec:windows 0 1 name' | 'rspec:windows name'
+ 'rspec:windows 0/1' | 'rspec:windows'
+ 'rspec:windows 0/1 name' | 'rspec:windows name'
+ 'rspec:windows 0:1' | 'rspec:windows'
+ 'rspec:windows 0:1 name' | 'rspec:windows name'
+ 'rspec:windows 10000 20000' | 'rspec:windows'
+ 'rspec:windows 0 : / 1' | 'rspec:windows'
+ 'rspec:windows 0 : / 1 name' | 'rspec:windows name'
+ '0 1 name ruby' | 'name ruby'
+ '0 :/ 1 name ruby' | 'name ruby'
+ 'rspec: [aws]' | 'rspec'
+ 'rspec: [aws] 0/1' | 'rspec'
+ 'rspec: [aws, max memory]' | 'rspec'
+ 'rspec:linux: [aws, max memory, data]' | 'rspec:linux'
+ 'rspec: [inception: [something, other thing], value]' | 'rspec'
+ 'rspec:windows 0/1: [name, other]' | 'rspec:windows'
+ 'rspec:windows: [name, other] 0/1' | 'rspec:windows'
+ 'rspec:windows: [name, 0/1] 0/1' | 'rspec:windows'
+ 'rspec:windows: [0/1, name]' | 'rspec:windows'
+ 'rspec:windows: [, ]' | 'rspec:windows'
+ 'rspec:windows: [name]' | 'rspec:windows'
+ 'rspec:windows: [name,other]' | 'rspec:windows'
+ end
+
+ with_them do
+ it "#{params[:name]} puts in #{params[:group_name]}" do
+ commit_status.name = name
- let(:commit_status) do
- build(:commit_status, pipeline: pipeline, stage: 'test')
- end
-
- subject { commit_status.group_name }
-
- tests = {
- 'rspec:windows' => 'rspec:windows',
- 'rspec:windows 0' => 'rspec:windows 0',
- 'rspec:windows 0 test' => 'rspec:windows 0 test',
- 'rspec:windows 0 1' => 'rspec:windows',
- 'rspec:windows 0 1 name' => 'rspec:windows name',
- 'rspec:windows 0/1' => 'rspec:windows',
- 'rspec:windows 0/1 name' => 'rspec:windows name',
- 'rspec:windows 0:1' => 'rspec:windows',
- 'rspec:windows 0:1 name' => 'rspec:windows name',
- 'rspec:windows 10000 20000' => 'rspec:windows',
- 'rspec:windows 0 : / 1' => 'rspec:windows',
- 'rspec:windows 0 : / 1 name' => 'rspec:windows name',
- '0 1 name ruby' => 'name ruby',
- '0 :/ 1 name ruby' => 'name ruby',
- 'rspec: [aws]' => 'rspec',
- 'rspec: [aws] 0/1' => 'rspec',
- 'rspec: [aws, max memory]' => 'rspec',
- 'rspec:linux: [aws, max memory, data]' => 'rspec:linux',
- 'rspec: [inception: [something, other thing], value]' => 'rspec',
- 'rspec:windows 0/1: [name, other]' => 'rspec:windows',
- 'rspec:windows: [name, other] 0/1' => 'rspec:windows',
- 'rspec:windows: [name, 0/1] 0/1' => 'rspec:windows',
- 'rspec:windows: [0/1, name]' => 'rspec:windows',
- 'rspec:windows: [, ]' => 'rspec:windows',
- 'rspec:windows: [name]' => 'rspec:windows',
- 'rspec:windows: [name,other]' => 'rspec:windows'
- }
-
- tests.each do |name, group_name|
- it "'#{name}' puts in '#{group_name}'" do
- commit_status.name = name
-
- is_expected.to eq(group_name)
- end
+ is_expected.to eq(group_name)
end
end
end
diff --git a/spec/models/concerns/atomic_internal_id_spec.rb b/spec/models/concerns/atomic_internal_id_spec.rb
index 8c3537f1dcc..5ee3c012dc9 100644
--- a/spec/models/concerns/atomic_internal_id_spec.rb
+++ b/spec/models/concerns/atomic_internal_id_spec.rb
@@ -86,4 +86,20 @@ RSpec.describe AtomicInternalId do
expect { subject }.to change { milestone.iid }.from(nil).to(iid.to_i)
end
end
+
+ describe '.with_project_iid_supply' do
+ let(:iid) { 100 }
+
+ it 'wraps generate and track_greatest in a concurrency-safe lock' do
+ expect_next_instance_of(InternalId::InternalIdGenerator) do |g|
+ expect(g).to receive(:with_lock).and_call_original
+ expect(g.record).to receive(:last_value).and_return(iid)
+ expect(g).to receive(:track_greatest).with(iid + 4)
+ end
+
+ ::Milestone.with_project_iid_supply(milestone.project) do |supply|
+ 4.times { supply.next_value }
+ end
+ end
+ end
end
diff --git a/spec/models/concerns/from_union_spec.rb b/spec/models/concerns/from_union_spec.rb
index bd2893090a8..4f4d948fe48 100644
--- a/spec/models/concerns/from_union_spec.rb
+++ b/spec/models/concerns/from_union_spec.rb
@@ -3,13 +3,5 @@
require 'spec_helper'
RSpec.describe FromUnion do
- [true, false].each do |sql_set_operator|
- context "when sql-set-operators feature flag is #{sql_set_operator}" do
- before do
- stub_feature_flags(sql_set_operators: sql_set_operator)
- end
-
- it_behaves_like 'from set operator', Gitlab::SQL::Union
- end
- end
+ it_behaves_like 'from set operator', Gitlab::SQL::Union
end
diff --git a/spec/models/concerns/optionally_search_spec.rb b/spec/models/concerns/optionally_search_spec.rb
index c8e2e6da51f..8067ad50322 100644
--- a/spec/models/concerns/optionally_search_spec.rb
+++ b/spec/models/concerns/optionally_search_spec.rb
@@ -32,7 +32,7 @@ RSpec.describe OptionallySearch do
it 'delegates to the search method' do
expect(model)
.to receive(:search)
- .with('foo', {})
+ .with('foo')
.and_call_original
expect(model.optionally_search('foo')).to eq(['foo', {}])
diff --git a/spec/models/container_expiration_policy_spec.rb b/spec/models/container_expiration_policy_spec.rb
index 1d9dbe8a867..32ec5ed161a 100644
--- a/spec/models/container_expiration_policy_spec.rb
+++ b/spec/models/container_expiration_policy_spec.rb
@@ -38,10 +38,43 @@ RSpec.describe ContainerExpirationPolicy, type: :model do
it { is_expected.not_to allow_value('foo').for(:keep_n) }
end
+ describe '#disable!' do
+ let_it_be(:policy) { create(:container_expiration_policy) }
+
+ subject { policy.disable! }
+
+ it 'disables the container expiration policy' do
+ expect { subject }.to change { policy.reload.enabled }.from(true).to(false)
+ end
+ end
+
+ describe '#policy_params' do
+ let_it_be(:policy) { create(:container_expiration_policy) }
+
+ let(:expected) do
+ {
+ 'older_than' => policy.older_than,
+ 'keep_n' => policy.keep_n,
+ 'name_regex' => policy.name_regex,
+ 'name_regex_keep' => policy.name_regex_keep
+ }
+ end
+
+ subject { policy.policy_params }
+
+ it { is_expected.to eq(expected) }
+ end
+
context 'with a set of regexps' do
+ let_it_be(:container_expiration_policy) { create(:container_expiration_policy) }
+
+ subject { container_expiration_policy }
+
valid_regexps = %w[master .* v.+ v10.1.* (?:v.+|master|release)]
invalid_regexps = ['[', '(?:v.+|master|release']
+ it { is_expected.to validate_presence_of(:name_regex) }
+
valid_regexps.each do |valid_regexp|
it { is_expected.to allow_value(valid_regexp).for(:name_regex) }
it { is_expected.to allow_value(valid_regexp).for(:name_regex_keep) }
@@ -57,6 +90,8 @@ RSpec.describe ContainerExpirationPolicy, type: :model do
subject { container_expiration_policy }
+ it { is_expected.not_to validate_presence_of(:name_regex) }
+
valid_regexps.each do |valid_regexp|
it { is_expected.to allow_value(valid_regexp).for(:name_regex) }
it { is_expected.to allow_value(valid_regexp).for(:name_regex_keep) }
@@ -104,25 +139,15 @@ RSpec.describe ContainerExpirationPolicy, type: :model do
end
end
- describe '.executable' do
- subject { described_class.executable }
+ describe '.with_container_repositories' do
+ subject { described_class.with_container_repositories }
- let_it_be(:policy1) { create(:container_expiration_policy, :runnable) }
+ let_it_be(:policy1) { create(:container_expiration_policy) }
let_it_be(:container_repository1) { create(:container_repository, project: policy1.project) }
- let_it_be(:policy2) { create(:container_expiration_policy, :runnable) }
+ let_it_be(:policy2) { create(:container_expiration_policy) }
let_it_be(:container_repository2) { create(:container_repository, project: policy2.project) }
- let_it_be(:policy3) { create(:container_expiration_policy, :runnable) }
+ let_it_be(:policy3) { create(:container_expiration_policy) }
it { is_expected.to contain_exactly(policy1, policy2) }
end
-
- describe '#disable!' do
- let_it_be(:container_expiration_policy) { create(:container_expiration_policy) }
-
- subject { container_expiration_policy.disable! }
-
- it 'disables the container expiration policy' do
- expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false)
- end
- end
end
diff --git a/spec/models/container_repository_spec.rb b/spec/models/container_repository_spec.rb
index 2a7aaed5204..2adceb1c960 100644
--- a/spec/models/container_repository_spec.rb
+++ b/spec/models/container_repository_spec.rb
@@ -352,4 +352,20 @@ RSpec.describe ContainerRepository do
it { is_expected.to contain_exactly(repository) }
end
+
+ describe '.for_project_id' do
+ subject { described_class.for_project_id(project.id) }
+
+ it { is_expected.to contain_exactly(repository) }
+ end
+
+ describe '.waiting_for_cleanup' do
+ let_it_be(:repository_cleanup_scheduled) { create(:container_repository, :cleanup_scheduled) }
+ let_it_be(:repository_cleanup_unfinished) { create(:container_repository, :cleanup_unfinished) }
+ let_it_be(:repository_cleanup_ongoing) { create(:container_repository, :cleanup_ongoing) }
+
+ subject { described_class.waiting_for_cleanup }
+
+ it { is_expected.to contain_exactly(repository_cleanup_scheduled, repository_cleanup_unfinished) }
+ end
end
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
index 836c4139107..62380299ea0 100644
--- a/spec/models/custom_emoji_spec.rb
+++ b/spec/models/custom_emoji_spec.rb
@@ -13,20 +13,28 @@ RSpec.describe CustomEmoji do
describe 'exclusion of duplicated emoji' do
let(:emoji_name) { Gitlab::Emoji.emojis_names.sample }
+ let(:group) { create(:group, :private) }
it 'disallows emoji names of built-in emoji' do
- new_emoji = build(:custom_emoji, name: emoji_name)
+ new_emoji = build(:custom_emoji, name: emoji_name, group: group)
expect(new_emoji).not_to be_valid
expect(new_emoji.errors.messages).to eq(name: ["#{emoji_name} is already being used for another emoji"])
end
it 'disallows duplicate custom emoji names within namespace' do
- old_emoji = create(:custom_emoji)
- new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace)
+ old_emoji = create(:custom_emoji, group: group)
+ new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)
expect(new_emoji).not_to be_valid
expect(new_emoji.errors.messages).to eq(name: ["has already been taken"])
end
+
+ it 'disallows non http and https file value' do
+ emoji = build(:custom_emoji, name: 'new-name', group: group, file: 'ftp://some-url.in')
+
+ expect(emoji).not_to be_valid
+ expect(emoji.errors.messages).to eq(file: ["is blocked: Only allowed schemes are http, https"])
+ end
end
end
diff --git a/spec/models/dependency_proxy/blob_spec.rb b/spec/models/dependency_proxy/blob_spec.rb
new file mode 100644
index 00000000000..7c8a1eb95e8
--- /dev/null
+++ b/spec/models/dependency_proxy/blob_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::Blob, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+ it { is_expected.to validate_presence_of(:file) }
+ it { is_expected.to validate_presence_of(:file_name) }
+ end
+
+ describe '.total_size' do
+ it 'returns 0 if no files' do
+ expect(described_class.total_size).to eq(0)
+ end
+
+ it 'returns a correct sum of all files sizes' do
+ create(:dependency_proxy_blob, size: 10)
+ create(:dependency_proxy_blob, size: 20)
+
+ expect(described_class.total_size).to eq(30)
+ end
+ end
+
+ describe '.find_or_build' do
+ let!(:blob) { create(:dependency_proxy_blob) }
+
+ it 'builds new instance if not found' do
+ expect(described_class.find_or_build('foo.gz')).not_to be_persisted
+ end
+
+ it 'finds an existing blob' do
+ expect(described_class.find_or_build(blob.file_name)).to eq(blob)
+ end
+ end
+
+ describe 'file is being stored' do
+ subject { create(:dependency_proxy_blob) }
+
+ context 'when existing object has local store' do
+ it_behaves_like 'mounted file in local store'
+ end
+
+ context 'when direct upload is enabled' do
+ before do
+ stub_dependency_proxy_object_storage(direct_upload: true)
+ end
+
+ it_behaves_like 'mounted file in object store'
+ end
+ end
+end
diff --git a/spec/models/dependency_proxy/group_setting_spec.rb b/spec/models/dependency_proxy/group_setting_spec.rb
new file mode 100644
index 00000000000..c4c4a877d50
--- /dev/null
+++ b/spec/models/dependency_proxy/group_setting_spec.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::GroupSetting, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:group) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:group) }
+ end
+end
diff --git a/spec/models/dependency_proxy/registry_spec.rb b/spec/models/dependency_proxy/registry_spec.rb
new file mode 100644
index 00000000000..5bfa75a2eed
--- /dev/null
+++ b/spec/models/dependency_proxy/registry_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::Registry, type: :model do
+ let(:tag) { '2.3.5-alpine' }
+ let(:blob_sha) { '40bd001563085fc35165329ea1ff5c5ecbdbbeef' }
+
+ context 'image name without namespace' do
+ let(:image) { 'ruby' }
+
+ describe '#auth_url' do
+ it 'returns a correct auth url' do
+ expect(described_class.auth_url(image))
+ .to eq('https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/ruby:pull')
+ end
+ end
+
+ describe '#manifest_url' do
+ it 'returns a correct manifest url' do
+ expect(described_class.manifest_url(image, tag))
+ .to eq('https://registry-1.docker.io/v2/library/ruby/manifests/2.3.5-alpine')
+ end
+ end
+
+ describe '#blob_url' do
+ it 'returns a correct blob url' do
+ expect(described_class.blob_url(image, blob_sha))
+ .to eq('https://registry-1.docker.io/v2/library/ruby/blobs/40bd001563085fc35165329ea1ff5c5ecbdbbeef')
+ end
+ end
+ end
+
+ context 'image name with namespace' do
+ let(:image) { 'foo/ruby' }
+
+ describe '#auth_url' do
+ it 'returns a correct auth url' do
+ expect(described_class.auth_url(image))
+ .to eq('https://auth.docker.io/token?service=registry.docker.io&scope=repository:foo/ruby:pull')
+ end
+ end
+
+ describe '#manifest_url' do
+ it 'returns a correct manifest url' do
+ expect(described_class.manifest_url(image, tag))
+ .to eq('https://registry-1.docker.io/v2/foo/ruby/manifests/2.3.5-alpine')
+ end
+ end
+
+ describe '#blob_url' do
+ it 'returns a correct blob url' do
+ expect(described_class.blob_url(image, blob_sha))
+ .to eq('https://registry-1.docker.io/v2/foo/ruby/blobs/40bd001563085fc35165329ea1ff5c5ecbdbbeef')
+ end
+ end
+ end
+end
diff --git a/spec/models/deploy_key_spec.rb b/spec/models/deploy_key_spec.rb
index 00114a94b56..d4ccaa6a10e 100644
--- a/spec/models/deploy_key_spec.rb
+++ b/spec/models/deploy_key_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe DeployKey, :mailer do
describe "Associations" do
it { is_expected.to have_many(:deploy_keys_projects) }
it { is_expected.to have_many(:projects) }
+ it { is_expected.to have_many(:protected_branch_push_access_levels) }
end
describe 'notification' do
@@ -40,4 +41,56 @@ RSpec.describe DeployKey, :mailer do
end
end
end
+
+ describe '.with_write_access_for_project' do
+ let_it_be(:project) { create(:project, :private) }
+
+ subject { described_class.with_write_access_for_project(project) }
+
+ context 'when no project is passed in' do
+ let(:project) { nil }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when a project is passed in' do
+ let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project) }
+ let_it_be(:deploy_key) { deploy_keys_project.deploy_key }
+
+ it 'only returns deploy keys with write access' do
+ create(:deploy_keys_project, project: project)
+
+ is_expected.to contain_exactly(deploy_key)
+ end
+
+ it 'returns deploy keys only for this project' do
+ other_project = create(:project)
+ create(:deploy_keys_project, :write_access, project: other_project)
+
+ is_expected.to contain_exactly(deploy_key)
+ end
+
+ context 'and a specific deploy key is passed in' do
+ subject { described_class.with_write_access_for_project(project, deploy_key: specific_deploy_key) }
+
+ context 'and this deploy key is not linked to the project' do
+ let(:specific_deploy_key) { create(:deploy_key) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'and this deploy key has not write access to the project' do
+ let(:specific_deploy_key) { create(:deploy_key, deploy_keys_projects: [create(:deploy_keys_project, project: project)]) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'and this deploy key has write access to the project' do
+ let(:specific_deploy_key) { create(:deploy_key, deploy_keys_projects: [create(:deploy_keys_project, :write_access, project: project)]) }
+
+ it { is_expected.to contain_exactly(specific_deploy_key) }
+ end
+ end
+ end
+ end
end
diff --git a/spec/models/deploy_keys_project_spec.rb b/spec/models/deploy_keys_project_spec.rb
index 7dd4d3129de..ccc2c64e02c 100644
--- a/spec/models/deploy_keys_project_spec.rb
+++ b/spec/models/deploy_keys_project_spec.rb
@@ -13,21 +13,6 @@ RSpec.describe DeployKeysProject do
it { is_expected.to validate_presence_of(:deploy_key) }
end
- describe '.with_deploy_keys' do
- subject(:scoped_query) { described_class.with_deploy_keys.last }
-
- it 'includes deploy_keys in query' do
- project = create(:project)
- create(:deploy_keys_project, project: project, deploy_key: create(:deploy_key))
-
- includes_query_count = ActiveRecord::QueryRecorder.new { scoped_query }.count
- deploy_key_query_count = ActiveRecord::QueryRecorder.new { scoped_query.deploy_key }.count
-
- expect(includes_query_count).to eq(2)
- expect(deploy_key_query_count).to eq(0)
- end
- end
-
describe "Destroying" do
let(:project) { create(:project) }
subject { create(:deploy_keys_project, project: project) }
diff --git a/spec/models/deploy_token_spec.rb b/spec/models/deploy_token_spec.rb
index 60a3e3fc0e2..c7e1d5fc0d5 100644
--- a/spec/models/deploy_token_spec.rb
+++ b/spec/models/deploy_token_spec.rb
@@ -124,6 +124,39 @@ RSpec.describe DeployToken do
end
end
+ # override the default PolicyActor implementation that always returns false
+ describe "#deactivated?" do
+ context "when it has been revoked" do
+ it 'returns true' do
+ deploy_token.revoke!
+
+ expect(deploy_token.deactivated?).to be_truthy
+ end
+ end
+
+ context "when it hasn't been revoked and is not expired" do
+ it 'returns false' do
+ expect(deploy_token.deactivated?).to be_falsy
+ end
+ end
+
+ context "when it hasn't been revoked and is expired" do
+ it 'returns false' do
+ deploy_token.update_attribute(:expires_at, Date.today - 5.days)
+
+ expect(deploy_token.deactivated?).to be_truthy
+ end
+ end
+
+ context "when it hasn't been revoked and has no expiry" do
+ let(:deploy_token) { create(:deploy_token, expires_at: nil) }
+
+ it 'returns false' do
+ expect(deploy_token.deactivated?).to be_falsy
+ end
+ end
+ end
+
describe '#username' do
context 'persisted records' do
it 'returns a default username if none is set' do
diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb
index 3e855584c38..9afacd518af 100644
--- a/spec/models/deployment_spec.rb
+++ b/spec/models/deployment_spec.rb
@@ -114,14 +114,6 @@ RSpec.describe Deployment do
deployment.run!
end
- it 'does not execute Deployments::ExecuteHooksWorker when feature is disabled' do
- stub_feature_flags(ci_send_deployment_hook_when_start: false)
- expect(Deployments::ExecuteHooksWorker)
- .not_to receive(:perform_async).with(deployment.id)
-
- deployment.run!
- end
-
it 'executes Deployments::DropOlderDeploymentsWorker asynchronously' do
expect(Deployments::DropOlderDeploymentsWorker)
.to receive(:perform_async).once.with(deployment.id)
diff --git a/spec/models/design_management/design_at_version_spec.rb b/spec/models/design_management/design_at_version_spec.rb
index 220de80a52a..a7cf6a9652b 100644
--- a/spec/models/design_management/design_at_version_spec.rb
+++ b/spec/models/design_management/design_at_version_spec.rb
@@ -185,7 +185,7 @@ RSpec.describe DesignManagement::DesignAtVersion do
end
describe 'validations' do
- subject(:design_at_version) { build(:design_at_version) }
+ subject(:design_at_version) { build_stubbed(:design_at_version) }
it { is_expected.to be_valid }
diff --git a/spec/models/design_management/design_spec.rb b/spec/models/design_management/design_spec.rb
index 2ce9f00a056..d3ce2f2d48f 100644
--- a/spec/models/design_management/design_spec.rb
+++ b/spec/models/design_management/design_spec.rb
@@ -11,6 +11,14 @@ RSpec.describe DesignManagement::Design do
let_it_be(:design3) { create(:design, :with_versions, issue: issue, versions_count: 1) }
let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
+ it_behaves_like 'AtomicInternalId', validate_presence: true do
+ let(:internal_id_attribute) { :iid }
+ let(:instance) { build(:design, issue: issue) }
+ let(:scope) { :project }
+ let(:scope_attrs) { { project: instance.project } }
+ let(:usage) { :design_management_designs }
+ end
+
it_behaves_like 'a class that supports relative positioning' do
let_it_be(:relative_parent) { create(:issue) }
@@ -23,8 +31,20 @@ RSpec.describe DesignManagement::Design do
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(:authors) }
it { is_expected.to have_many(:notes).dependent(:delete_all) }
it { is_expected.to have_many(:user_mentions) }
+
+ describe '#authors' do
+ it 'returns unique version authors', :aggregate_failures do
+ author = create(:user)
+ create_list(:design_version, 2, designs: [design1], author: author)
+ version_authors = design1.versions.map(&:author)
+
+ expect(version_authors).to contain_exactly(issue.author, author, author)
+ expect(design1.authors).to contain_exactly(issue.author, author)
+ end
+ end
end
describe 'validations' do
@@ -326,6 +346,38 @@ RSpec.describe DesignManagement::Design do
end
end
+ describe '#participants' do
+ let_it_be_with_refind(:design) { create(:design, issue: issue) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:version_author) { create(:user) }
+ let_it_be(:note_author) { create(:user) }
+ let_it_be(:mentioned_user) { create(:user) }
+ let_it_be(:design_version) { create(:design_version, :committed, designs: [design], author: version_author) }
+ let_it_be(:note) do
+ create(:diff_note_on_design,
+ noteable: design,
+ issue: issue,
+ project: issue.project,
+ author: note_author,
+ note: mentioned_user.to_reference
+ )
+ end
+
+ subject { design.participants(current_user) }
+
+ it { is_expected.to be_empty }
+
+ context 'when participants can read the project' do
+ before do
+ design.project.add_guest(version_author)
+ design.project.add_guest(note_author)
+ design.project.add_guest(mentioned_user)
+ end
+
+ it { is_expected.to contain_exactly(version_author, note_author, mentioned_user) }
+ end
+ end
+
describe "#new_design?" do
let(:design) { design1 }
diff --git a/spec/models/design_management/version_spec.rb b/spec/models/design_management/version_spec.rb
index cd52f4129dc..e004ad024bc 100644
--- a/spec/models/design_management/version_spec.rb
+++ b/spec/models/design_management/version_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe DesignManagement::Version do
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_presence_of(:issue) }
it { is_expected.to validate_uniqueness_of(:sha).scoped_to(:issue_id).case_insensitive }
end
diff --git a/spec/models/diff_viewer/image_spec.rb b/spec/models/diff_viewer/image_spec.rb
new file mode 100644
index 00000000000..e959a7d5eb2
--- /dev/null
+++ b/spec/models/diff_viewer/image_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DiffViewer::Image do
+ describe '.can_render?' do
+ let(:diff_file) { double(Gitlab::Diff::File) }
+ let(:blob) { double(Gitlab::Git::Blob, binary_in_repo?: true, extension: 'png') }
+
+ subject { described_class.can_render?(diff_file, verify_binary: false) }
+
+ it 'returns false if both old and new blob are absent' do
+ allow(diff_file).to receive(:old_blob) { nil }
+ allow(diff_file).to receive(:new_blob) { nil }
+
+ is_expected.to be_falsy
+ end
+
+ it 'returns true if the old blob is present' do
+ allow(diff_file).to receive(:old_blob) { blob }
+ allow(diff_file).to receive(:new_blob) { nil }
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns true if the new blob is present' do
+ allow(diff_file).to receive(:old_blob) { nil }
+ allow(diff_file).to receive(:new_blob) { blob }
+
+ is_expected.to be_truthy
+ end
+
+ it 'returns true if both old and new blobs are present' do
+ allow(diff_file).to receive(:old_blob) { blob }
+ allow(diff_file).to receive(:new_blob) { blob }
+
+ is_expected.to be_truthy
+ end
+ end
+end
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 06d3e9da286..179f2a1b0e0 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -982,6 +982,22 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#has_running_deployments?' do
+ subject { environment.has_running_deployments? }
+
+ it 'return false when no deployments exist' do
+ is_expected.to eq(false)
+ end
+
+ context 'when deployment is running on the environment' do
+ let!(:deployment) { create(:deployment, :running, environment: environment) }
+
+ it 'return true' do
+ is_expected.to eq(true)
+ end
+ end
+ end
+
describe '#metrics' do
let(:project) { create(:prometheus_project) }
diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb
index 64cd2da4621..587f410c9be 100644
--- a/spec/models/experiment_spec.rb
+++ b/spec/models/experiment_spec.rb
@@ -7,32 +7,6 @@ RSpec.describe Experiment do
describe 'associations' do
it { is_expected.to have_many(:experiment_users) }
- it { is_expected.to have_many(:users) }
- it { is_expected.to have_many(:control_group_users) }
- it { is_expected.to have_many(:experimental_group_users) }
-
- describe 'control_group_users and experimental_group_users' do
- let(:experiment) { create(:experiment) }
- let(:control_group_user) { build(:user) }
- let(:experimental_group_user) { build(:user) }
-
- before do
- experiment.control_group_users << control_group_user
- experiment.experimental_group_users << experimental_group_user
- end
-
- describe 'control_group_users' do
- subject { experiment.control_group_users }
-
- it { is_expected.to contain_exactly(control_group_user) }
- end
-
- describe 'experimental_group_users' do
- subject { experiment.experimental_group_users }
-
- it { is_expected.to contain_exactly(experimental_group_user) }
- end
- end
end
describe 'validations' do
@@ -42,71 +16,83 @@ RSpec.describe Experiment do
end
describe '.add_user' do
- let(:name) { :experiment_key }
- let(:user) { build(:user) }
+ let_it_be(:experiment_name) { :experiment_key }
+ let_it_be(:user) { 'a user' }
+ let_it_be(:group) { 'a group' }
- let!(:experiment) { create(:experiment, name: name) }
+ subject(:add_user) { described_class.add_user(experiment_name, group, user) }
- subject { described_class.add_user(name, :control, user) }
-
- describe 'creating a new experiment record' do
- context 'an experiment with the provided name already exists' do
- it 'does not create a new experiment record' do
- expect { subject }.not_to change(Experiment, :count)
+ context 'when an experiment with the provided name does not exist' do
+ it 'creates a new experiment record' do
+ allow_next_instance_of(described_class) do |experiment|
+ allow(experiment).to receive(:record_user_and_group).with(user, group)
end
+ expect { add_user }.to change(described_class, :count).by(1)
end
- context 'an experiment with the provided name does not exist yet' do
- let(:experiment) { nil }
-
- it 'creates a new experiment record' do
- expect { subject }.to change(Experiment, :count).by(1)
+ it 'forwards the user and group_type to the instance' do
+ expect_next_instance_of(described_class) do |experiment|
+ expect(experiment).to receive(:record_user_and_group).with(user, group)
end
+ add_user
end
end
- describe 'creating a new experiment_user record' do
- context 'an experiment_user record for this experiment already exists' do
- before do
- subject
- end
+ context 'when an experiment with the provided name already exists' do
+ let_it_be(:experiment) { create(:experiment, name: experiment_name) }
- it 'does not create a new experiment_user record' do
- expect { subject }.not_to change(ExperimentUser, :count)
+ it 'does not create a new experiment record' do
+ allow_next_found_instance_of(described_class) do |experiment|
+ allow(experiment).to receive(:record_user_and_group).with(user, group)
end
+ expect { add_user }.not_to change(described_class, :count)
end
- context 'an experiment_user record for this experiment does not exist yet' do
- it 'creates a new experiment_user record' do
- expect { subject }.to change(ExperimentUser, :count).by(1)
- end
-
- it 'assigns the correct group_type to the experiment_user' do
- expect { subject }.to change { experiment.control_group_users.count }.by(1)
+ it 'forwards the user and group_type to the instance' do
+ expect_next_found_instance_of(described_class) do |experiment|
+ expect(experiment).to receive(:record_user_and_group).with(user, group)
end
+ add_user
end
end
end
- describe '#add_control_user' do
- let(:experiment) { create(:experiment) }
- let(:user) { build(:user) }
+ describe '#record_user_and_group' do
+ let_it_be(:experiment) { create(:experiment) }
+ let_it_be(:user) { create(:user) }
- subject { experiment.add_control_user(user) }
+ let(:group) { :control }
- it 'creates a new experiment_user record and assigns the correct group_type' do
- expect { subject }.to change { experiment.control_group_users.count }.by(1)
+ subject(:record_user_and_group) { experiment.record_user_and_group(user, group) }
+
+ context 'when an experiment_user does not yet exist for the given user' do
+ it 'creates a new experiment_user record' do
+ expect { record_user_and_group }.to change(ExperimentUser, :count).by(1)
+ end
+
+ it 'assigns the correct group_type to the experiment_user' do
+ record_user_and_group
+ expect(ExperimentUser.last.group_type).to eq('control')
+ end
end
- end
- describe '#add_experimental_user' do
- let(:experiment) { create(:experiment) }
- let(:user) { build(:user) }
+ context 'when an experiment_user already exists for the given user' do
+ before do
+ # Create an existing experiment_user for this experiment and the :control group
+ experiment.record_user_and_group(user, :control)
+ end
+
+ it 'does not create a new experiment_user record' do
+ expect { record_user_and_group }.not_to change(ExperimentUser, :count)
+ end
- subject { experiment.add_experimental_user(user) }
+ context 'but the group_type has changed' do
+ let(:group) { :experimental }
- it 'creates a new experiment_user record and assigns the correct group_type' do
- expect { subject }.to change { experiment.experimental_group_users.count }.by(1)
+ it 'updates the existing experiment_user record' do
+ expect { record_user_and_group }.to change { ExperimentUser.last.group_type }
+ end
+ end
end
end
end
diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb
index cc29e20710a..dd1faf999b3 100644
--- a/spec/models/group_spec.rb
+++ b/spec/models/group_spec.rb
@@ -28,6 +28,8 @@ RSpec.describe Group do
it { is_expected.to have_many(:iterations) }
it { is_expected.to have_many(:group_deploy_keys) }
it { is_expected.to have_many(:services) }
+ it { is_expected.to have_one(:dependency_proxy_setting) }
+ it { is_expected.to have_many(:dependency_proxy_blobs) }
describe '#members & #requesters' do
let(:requester) { create(:user) }
@@ -308,8 +310,10 @@ RSpec.describe Group do
end
describe 'scopes' do
- let!(:private_group) { create(:group, :private) }
- let!(:internal_group) { create(:group, :internal) }
+ let_it_be(:private_group) { create(:group, :private) }
+ let_it_be(:internal_group) { create(:group, :internal) }
+ let_it_be(:user1) { create(:user) }
+ let_it_be(:user2) { create(:user) }
describe 'public_only' do
subject { described_class.public_only.to_a }
@@ -328,6 +332,27 @@ RSpec.describe Group do
it { is_expected.to match_array([private_group, internal_group]) }
end
+
+ describe 'for_authorized_group_members' do
+ let_it_be(:group_member1) { create(:group_member, source: private_group, user_id: user1.id, access_level: Gitlab::Access::OWNER) }
+
+ it do
+ result = described_class.for_authorized_group_members([user1.id, user2.id])
+
+ expect(result).to match_array([private_group])
+ end
+ end
+
+ describe 'for_authorized_project_members' do
+ let_it_be(:project) { create(:project, group: internal_group) }
+ let_it_be(:project_member1) { create(:project_member, source: project, user_id: user1.id, access_level: Gitlab::Access::DEVELOPER) }
+
+ it do
+ result = described_class.for_authorized_project_members([user1.id, user2.id])
+
+ expect(result).to match_array([internal_group])
+ end
+ end
end
describe '#to_reference' do
@@ -944,23 +969,72 @@ RSpec.describe Group do
context 'expanded group members' do
let(:indirect_user) { create(:user) }
- it 'enables two_factor_requirement for subgroup member' do
- subgroup = create(:group, :nested, parent: group)
- subgroup.add_user(indirect_user, GroupMember::OWNER)
+ context 'two_factor_requirement is enabled' do
+ context 'two_factor_requirement is also enabled for ancestor group' do
+ it 'enables two_factor_requirement for subgroup member' do
+ subgroup = create(:group, :nested, parent: group)
+ subgroup.add_user(indirect_user, GroupMember::OWNER)
- group.update!(require_two_factor_authentication: true)
+ group.update!(require_two_factor_authentication: true)
+
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_truthy
+ end
+ end
+
+ context 'two_factor_requirement is disabled for ancestor group' do
+ it 'enables two_factor_requirement for subgroup member' do
+ subgroup = create(:group, :nested, parent: group, require_two_factor_authentication: true)
+ subgroup.add_user(indirect_user, GroupMember::OWNER)
+
+ group.update!(require_two_factor_authentication: false)
+
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_truthy
+ end
+
+ it 'enable two_factor_requirement for ancestor group member' do
+ ancestor_group = create(:group)
+ ancestor_group.add_user(indirect_user, GroupMember::OWNER)
+ group.update!(parent: ancestor_group)
+
+ group.update!(require_two_factor_authentication: true)
- expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_truthy
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_truthy
+ end
+ end
end
- it 'does not enable two_factor_requirement for ancestor group member' do
- ancestor_group = create(:group)
- ancestor_group.add_user(indirect_user, GroupMember::OWNER)
- group.update!(parent: ancestor_group)
+ context 'two_factor_requirement is disabled' do
+ context 'two_factor_requirement is enabled for ancestor group' do
+ it 'enables two_factor_requirement for subgroup member' do
+ subgroup = create(:group, :nested, parent: group)
+ subgroup.add_user(indirect_user, GroupMember::OWNER)
- group.update!(require_two_factor_authentication: true)
+ group.update!(require_two_factor_authentication: true)
+
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_truthy
+ end
+ end
+
+ context 'two_factor_requirement is also disabled for ancestor group' do
+ it 'disables two_factor_requirement for subgroup member' do
+ subgroup = create(:group, :nested, parent: group)
+ subgroup.add_user(indirect_user, GroupMember::OWNER)
- expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_falsey
+ group.update!(require_two_factor_authentication: false)
+
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_falsey
+ end
+
+ it 'disables two_factor_requirement for ancestor group member' do
+ ancestor_group = create(:group, require_two_factor_authentication: false)
+ indirect_user.update!(require_two_factor_authentication_from_group: true)
+ ancestor_group.add_user(indirect_user, GroupMember::OWNER)
+
+ group.update!(require_two_factor_authentication: false)
+
+ expect(indirect_user.reload.require_two_factor_authentication_from_group).to be_falsey
+ end
+ end
end
end
@@ -1591,4 +1665,47 @@ RSpec.describe Group do
end
end
end
+
+ describe 'has_project_with_service_desk_enabled?' do
+ let_it_be(:group) { create(:group, :private) }
+
+ subject { group.has_project_with_service_desk_enabled? }
+
+ before do
+ allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(true)
+ end
+
+ context 'when service desk is enabled' do
+ context 'for top level group' do
+ let_it_be(:project) { create(:project, group: group, service_desk_enabled: true) }
+
+ it { is_expected.to eq(true) }
+
+ context 'when service desk is not supported' do
+ before do
+ allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
+
+ context 'for subgroup project' do
+ let_it_be(:subgroup) { create(:group, :private, parent: group)}
+ let_it_be(:project) { create(:project, group: subgroup, service_desk_enabled: true) }
+
+ it { is_expected.to eq(true) }
+ end
+ end
+
+ context 'when none of group child projects has service desk enabled' do
+ let_it_be(:project) { create(:project, group: group, service_desk_enabled: false) }
+
+ before do
+ project.update(service_desk_enabled: false)
+ end
+
+ it { is_expected.to eq(false) }
+ end
+ end
end
diff --git a/spec/models/instance_metadata_spec.rb b/spec/models/instance_metadata_spec.rb
new file mode 100644
index 00000000000..1835dc8a9af
--- /dev/null
+++ b/spec/models/instance_metadata_spec.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+
+RSpec.describe InstanceMetadata do
+ it 'has the correct properties' do
+ expect(subject).to have_attributes(
+ version: Gitlab::VERSION,
+ revision: Gitlab.revision
+ )
+ end
+end
diff --git a/spec/models/internal_id_spec.rb b/spec/models/internal_id_spec.rb
index 751e8724872..07f62b9de55 100644
--- a/spec/models/internal_id_spec.rb
+++ b/spec/models/internal_id_spec.rb
@@ -6,8 +6,9 @@ RSpec.describe InternalId do
let(:project) { create(:project) }
let(:usage) { :issues }
let(:issue) { build(:issue, project: project) }
+ let(:id_subject) { issue }
let(:scope) { { project: project } }
- let(:init) { ->(s) { s.project.issues.size } }
+ let(:init) { ->(issue, scope) { issue&.project&.issues&.size || Issue.where(**scope).count } }
it_behaves_like 'having unique enum values'
@@ -39,7 +40,7 @@ RSpec.describe InternalId do
end
describe '.generate_next' do
- subject { described_class.generate_next(issue, scope, usage, init) }
+ subject { described_class.generate_next(id_subject, scope, usage, init) }
context 'in the absence of a record' do
it 'creates a record if not yet present' do
@@ -88,6 +89,14 @@ RSpec.describe InternalId do
expect(normalized).to eq((0..seq.size - 1).to_a)
end
+
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
+
+ it 'accepts classes instead' do
+ expect(subject).to eq(1)
+ end
+ end
end
describe '.reset' do
@@ -130,7 +139,7 @@ RSpec.describe InternalId do
describe '.track_greatest' do
let(:value) { 9001 }
- subject { described_class.track_greatest(issue, scope, usage, value, init) }
+ subject { described_class.track_greatest(id_subject, scope, usage, value, init) }
context 'in the absence of a record' do
it 'creates a record if not yet present' do
@@ -166,6 +175,14 @@ RSpec.describe InternalId do
expect(subject).to eq 10_001
end
end
+
+ context 'there are no instances to pass in' do
+ let(:id_subject) { Issue }
+
+ it 'accepts classes instead' do
+ expect(subject).to eq(value)
+ end
+ end
end
describe '#increment_and_save!' do
diff --git a/spec/models/issue_link_spec.rb b/spec/models/issue_link_spec.rb
index 00791d4a48b..ef41108ebea 100644
--- a/spec/models/issue_link_spec.rb
+++ b/spec/models/issue_link_spec.rb
@@ -27,7 +27,14 @@ RSpec.describe IssueLink do
.with_message(/already related/)
end
- context 'self relation' do
+ it 'is not valid if an opposite link already exists' do
+ issue_link = build(:issue_link, source: subject.target, target: subject.source)
+
+ expect(issue_link).to be_invalid
+ expect(issue_link.errors[:source]).to include('is already related to this issue')
+ end
+
+ context 'when it relates to itself' do
let(:issue) { create :issue }
context 'cannot be validated' do
diff --git a/spec/models/issues/csv_import_spec.rb b/spec/models/issues/csv_import_spec.rb
new file mode 100644
index 00000000000..2911a79e505
--- /dev/null
+++ b/spec/models/issues/csv_import_spec.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::CsvImport, type: :model do
+ describe 'associations' do
+ it { is_expected.to belong_to(:project).required }
+ it { is_expected.to belong_to(:user).required }
+ end
+end
diff --git a/spec/models/key_spec.rb b/spec/models/key_spec.rb
index 1e14864676c..3d33a39d353 100644
--- a/spec/models/key_spec.rb
+++ b/spec/models/key_spec.rb
@@ -108,7 +108,7 @@ RSpec.describe Key, :mailer do
expect(build(:key, key: 'ssh-rsa an-invalid-key==')).not_to be_valid
end
- where(:factory, :chars, :expected_sections) do
+ where(:factory, :characters, :expected_sections) do
[
[:key, ["\n", "\r\n"], 3],
[:key, [' ', ' '], 3],
@@ -122,7 +122,7 @@ RSpec.describe Key, :mailer do
let!(:original_fingerprint_sha256) { key.fingerprint_sha256 }
it 'accepts a key with blank space characters after stripping them' do
- modified_key = key.key.insert(100, chars.first).insert(40, chars.last)
+ modified_key = key.key.insert(100, characters.first).insert(40, characters.last)
_, content = modified_key.split
key.update!(key: modified_key)
diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb
index 118b1492cd6..1a791820f1b 100644
--- a/spec/models/member_spec.rb
+++ b/spec/models/member_spec.rb
@@ -252,12 +252,17 @@ RSpec.describe Member do
end
describe '.last_ten_days_excluding_today' do
- let_it_be(:created_today) { create(:group_member, created_at: Date.today.beginning_of_day) }
- let_it_be(:created_yesterday) { create(:group_member, created_at: 1.day.ago) }
- let_it_be(:created_eleven_days_ago) { create(:group_member, created_at: 11.days.ago) }
+ let_it_be(:now) { Time.current }
+ let_it_be(:created_today) { create(:group_member, created_at: now.beginning_of_day) }
+ let_it_be(:created_yesterday) { create(:group_member, created_at: now - 1.day) }
+ let_it_be(:created_eleven_days_ago) { create(:group_member, created_at: now - 11.days) }
subject { described_class.last_ten_days_excluding_today }
+ before do
+ travel_to now
+ end
+
it { is_expected.to include(created_yesterday) }
it { is_expected.not_to include(created_today, created_eleven_days_ago) }
end
diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb
index 9af620e70a5..2b24e2d6455 100644
--- a/spec/models/members/group_member_spec.rb
+++ b/spec/models/members/group_member_spec.rb
@@ -4,9 +4,10 @@ require 'spec_helper'
RSpec.describe GroupMember do
context 'scopes' do
+ let_it_be(:user_1) { create(:user) }
+ let_it_be(:user_2) { create(:user) }
+
it 'counts users by group ID' do
- user_1 = create(:user)
- user_2 = create(:user)
group_1 = create(:group)
group_2 = create(:group)
@@ -25,6 +26,15 @@ RSpec.describe GroupMember do
expect(described_class.of_ldap_type).to eq([group_member])
end
end
+
+ describe '.with_user' do
+ it 'returns requested user' do
+ group_member = create(:group_member, user: user_2)
+ create(:group_member, user: user_1)
+
+ expect(described_class.with_user(user_2)).to eq([group_member])
+ end
+ end
end
describe '.access_level_roles' do
diff --git a/spec/models/merge_request/cleanup_schedule_spec.rb b/spec/models/merge_request/cleanup_schedule_spec.rb
new file mode 100644
index 00000000000..925d287088b
--- /dev/null
+++ b/spec/models/merge_request/cleanup_schedule_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe MergeRequest::CleanupSchedule do
+ describe 'associations' do
+ it { is_expected.to belong_to(:merge_request) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:scheduled_at) }
+ end
+
+ describe '.scheduled_merge_request_ids' do
+ let_it_be(:mr_cleanup_schedule_1) { create(:merge_request_cleanup_schedule, scheduled_at: 2.days.ago) }
+ let_it_be(:mr_cleanup_schedule_2) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.ago) }
+ let_it_be(:mr_cleanup_schedule_3) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.ago, completed_at: Time.current) }
+ let_it_be(:mr_cleanup_schedule_4) { create(:merge_request_cleanup_schedule, scheduled_at: 4.days.ago) }
+ let_it_be(:mr_cleanup_schedule_5) { create(:merge_request_cleanup_schedule, scheduled_at: 3.days.ago) }
+ let_it_be(:mr_cleanup_schedule_6) { create(:merge_request_cleanup_schedule, scheduled_at: 1.day.from_now) }
+ let_it_be(:mr_cleanup_schedule_7) { create(:merge_request_cleanup_schedule, scheduled_at: 5.days.ago) }
+
+ it 'only includes incomplete schedule within the specified limit' do
+ expect(described_class.scheduled_merge_request_ids(4)).to eq([
+ mr_cleanup_schedule_2.merge_request_id,
+ mr_cleanup_schedule_1.merge_request_id,
+ mr_cleanup_schedule_5.merge_request_id,
+ mr_cleanup_schedule_4.merge_request_id
+ ])
+ end
+ end
+end
diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb
index ddb3ffdda2f..9574c57e46c 100644
--- a/spec/models/merge_request_spec.rb
+++ b/spec/models/merge_request_spec.rb
@@ -30,6 +30,7 @@ RSpec.describe MergeRequest, factory_default: :keep do
it { is_expected.to have_many(:resource_state_events) }
it { is_expected.to have_many(:draft_notes) }
it { is_expected.to have_many(:reviews).inverse_of(:merge_request) }
+ it { is_expected.to have_one(:cleanup_schedule).inverse_of(:merge_request) }
context 'for forks' do
let!(:project) { create(:project) }
@@ -79,6 +80,18 @@ RSpec.describe MergeRequest, factory_default: :keep do
end
end
+ describe '.with_jira_issue_keys' do
+ let_it_be(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'Fix TEST-123') }
+ let_it_be(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'this closes TEST-321') }
+ let_it_be(:mr_without_jira_reference) { create(:merge_request, :unique_branches) }
+
+ subject { described_class.with_jira_issue_keys }
+
+ it { is_expected.to contain_exactly(mr_with_jira_title, mr_with_jira_description) }
+
+ it { is_expected.not_to include(mr_without_jira_reference) }
+ end
+
describe '#squash_in_progress?' do
let(:repo_path) do
Gitlab::GitalyClient::StorageSettings.allow_disk_access do
diff --git a/spec/models/namespace/root_storage_statistics_spec.rb b/spec/models/namespace/root_storage_statistics_spec.rb
index 92a8d17a2a8..b725d2366a1 100644
--- a/spec/models/namespace/root_storage_statistics_spec.rb
+++ b/spec/models/namespace/root_storage_statistics_spec.rb
@@ -45,6 +45,7 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
total_storage_size = stat1.storage_size + stat2.storage_size
total_snippets_size = stat1.snippets_size + stat2.snippets_size
total_pipeline_artifacts_size = stat1.pipeline_artifacts_size + stat2.pipeline_artifacts_size
+ total_uploads_size = stat1.uploads_size + stat2.uploads_size
expect(root_storage_statistics.repository_size).to eq(total_repository_size)
expect(root_storage_statistics.wiki_size).to eq(total_wiki_size)
@@ -54,6 +55,7 @@ RSpec.describe Namespace::RootStorageStatistics, type: :model do
expect(root_storage_statistics.storage_size).to eq(total_storage_size)
expect(root_storage_statistics.snippets_size).to eq(total_snippets_size)
expect(root_storage_statistics.pipeline_artifacts_size).to eq(total_pipeline_artifacts_size)
+ expect(root_storage_statistics.uploads_size).to eq(total_uploads_size)
end
it 'works when there are no projects' do
diff --git a/spec/models/namespace_setting_spec.rb b/spec/models/namespace_setting_spec.rb
index c6e8d5b129c..59b7510051f 100644
--- a/spec/models/namespace_setting_spec.rb
+++ b/spec/models/namespace_setting_spec.rb
@@ -36,13 +36,10 @@ RSpec.describe NamespaceSetting, type: :model do
context "when an empty string" do
before do
- namespace_settings.default_branch_name = ''
+ namespace_settings.default_branch_name = ""
end
- it "returns an error" do
- expect(namespace_settings.valid?).to be_falsey
- expect(namespace_settings.errors.full_messages).not_to be_empty
- end
+ it_behaves_like "doesn't return an error"
end
end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 91b18f346c6..85f9005052e 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -147,30 +147,45 @@ RSpec.describe Namespace do
end
describe '.search' do
- let(:namespace) { create(:namespace) }
+ let_it_be(:first_namespace) { build(:namespace, name: 'my first namespace', path: 'old-path').tap(&:save!) }
+ let_it_be(:parent_namespace) { build(:namespace, name: 'my parent namespace', path: 'parent-path').tap(&:save!) }
+ let_it_be(:second_namespace) { build(:namespace, name: 'my second namespace', path: 'new-path', parent: parent_namespace).tap(&:save!) }
+ let_it_be(:project_with_same_path) { create(:project, id: second_namespace.id, path: first_namespace.path) }
it 'returns namespaces with a matching name' do
- expect(described_class.search(namespace.name)).to eq([namespace])
+ expect(described_class.search('my first namespace')).to eq([first_namespace])
end
it 'returns namespaces with a partially matching name' do
- expect(described_class.search(namespace.name[0..2])).to eq([namespace])
+ expect(described_class.search('first')).to eq([first_namespace])
end
it 'returns namespaces with a matching name regardless of the casing' do
- expect(described_class.search(namespace.name.upcase)).to eq([namespace])
+ expect(described_class.search('MY FIRST NAMESPACE')).to eq([first_namespace])
end
it 'returns namespaces with a matching path' do
- expect(described_class.search(namespace.path)).to eq([namespace])
+ expect(described_class.search('old-path')).to eq([first_namespace])
end
it 'returns namespaces with a partially matching path' do
- expect(described_class.search(namespace.path[0..2])).to eq([namespace])
+ expect(described_class.search('old')).to eq([first_namespace])
end
it 'returns namespaces with a matching path regardless of the casing' do
- expect(described_class.search(namespace.path.upcase)).to eq([namespace])
+ expect(described_class.search('OLD-PATH')).to eq([first_namespace])
+ end
+
+ it 'returns namespaces with a matching route path' do
+ expect(described_class.search('parent-path/new-path', include_parents: true)).to eq([second_namespace])
+ end
+
+ it 'returns namespaces with a partially matching route path' do
+ expect(described_class.search('parent-path/new', include_parents: true)).to eq([second_namespace])
+ end
+
+ it 'returns namespaces with a matching route path regardless of the casing' do
+ expect(described_class.search('PARENT-PATH/NEW-PATH', include_parents: true)).to eq([second_namespace])
end
end
@@ -672,7 +687,7 @@ RSpec.describe Namespace do
let!(:project) { create(:project_empty_repo, namespace: namespace) }
it 'has no repositories base directories to remove' do
- allow(GitlabShellWorker).to receive(:perform_in)
+ expect(GitlabShellWorker).not_to receive(:perform_in)
expect(File.exist?(path_in_dir)).to be(false)
@@ -855,8 +870,8 @@ RSpec.describe Namespace do
end
describe '#all_projects' do
- shared_examples 'all projects for a namespace' do
- let(:namespace) { create(:namespace) }
+ shared_examples 'all projects for a group' do
+ let(:namespace) { create(:group) }
let(:child) { create(:group, parent: namespace) }
let!(:project1) { create(:project_empty_repo, namespace: namespace) }
let!(:project2) { create(:project_empty_repo, namespace: child) }
@@ -865,30 +880,34 @@ RSpec.describe Namespace do
it { expect(child.all_projects.to_a).to match_array([project2]) }
end
- shared_examples 'all project examples' do
- include_examples 'all projects for a namespace'
+ shared_examples 'all projects for personal namespace' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:user_namespace) { create(:namespace, owner: user) }
+ let_it_be(:project) { create(:project, namespace: user_namespace) }
+
+ it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
+ end
+ context 'with recursive approach' do
context 'when namespace is a group' do
- let_it_be(:namespace) { create(:group) }
+ include_examples 'all projects for a group'
+
+ it 'queries for the namespace and its descendants' do
+ expect(Project).to receive(:where).with(namespace: [namespace, child])
- include_examples 'all projects for a namespace'
+ namespace.all_projects
+ end
end
context 'when namespace is a user namespace' do
- let_it_be(:user) { create(:user) }
- let_it_be(:user_namespace) { create(:namespace, owner: user) }
- let_it_be(:project) { create(:project, namespace: user_namespace) }
+ include_examples 'all projects for personal namespace'
- it { expect(user_namespace.all_projects.to_a).to match_array([project]) }
- end
- end
+ it 'only queries for the namespace itself' do
+ expect(Project).to receive(:where).with(namespace: user_namespace)
- context 'with recursive approach' do
- before do
- stub_feature_flags(recursive_approach_for_all_projects: true)
+ user_namespace.all_projects
+ end
end
-
- include_examples 'all project examples'
end
context 'with route path wildcard approach' do
@@ -896,7 +915,13 @@ RSpec.describe Namespace do
stub_feature_flags(recursive_approach_for_all_projects: false)
end
- include_examples 'all project examples'
+ context 'when namespace is a group' do
+ include_examples 'all projects for a group'
+ end
+
+ context 'when namespace is a user namespace' do
+ include_examples 'all projects for personal namespace'
+ end
end
end
@@ -1246,24 +1271,6 @@ RSpec.describe Namespace do
expect(virtual_domain.lookup_paths).not_to be_empty
end
end
-
- it 'preloads project_feature and route' do
- project2 = create(:project, namespace: namespace)
- project3 = create(:project, namespace: namespace)
-
- project.mark_pages_as_deployed
- project2.mark_pages_as_deployed
- project3.mark_pages_as_deployed
-
- virtual_domain = namespace.pages_virtual_domain
-
- queries = ActiveRecord::QueryRecorder.new { virtual_domain.lookup_paths }
-
- # 1 to load projects
- # 1 to preload project features
- # 1 to load routes
- expect(queries.count).to eq(3)
- end
end
end
diff --git a/spec/models/operations/feature_flag_spec.rb b/spec/models/operations/feature_flag_spec.rb
index b4e941f2856..93dd7d4f0bb 100644
--- a/spec/models/operations/feature_flag_spec.rb
+++ b/spec/models/operations/feature_flag_spec.rb
@@ -261,4 +261,38 @@ RSpec.describe Operations::FeatureFlag do
expect(flags.map(&:id)).to eq([feature_flag.id, feature_flag_b.id])
end
end
+
+ describe '#hook_attrs' do
+ it 'includes expected attributes' do
+ hook_attrs = {
+ id: subject.id,
+ name: subject.name,
+ description: subject.description,
+ active: subject.active
+ }
+ expect(subject.hook_attrs).to eq(hook_attrs)
+ end
+ end
+
+ describe "#execute_hooks" do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:feature_flag) { create(:operations_feature_flag, project: project) }
+
+ it 'does not execute the hook when feature_flag event is disabled' do
+ create(:project_hook, project: project, feature_flag_events: false)
+ expect(WebHookWorker).not_to receive(:perform_async)
+
+ feature_flag.execute_hooks(user)
+ feature_flag.touch
+ end
+
+ it 'executes hook when feature_flag event is enabled' do
+ hook = create(:project_hook, project: project, feature_flag_events: true)
+ expect(WebHookWorker).to receive(:perform_async).with(hook.id, an_instance_of(Hash), 'feature_flag_hooks')
+
+ feature_flag.execute_hooks(user)
+ feature_flag.touch
+ end
+ end
end
diff --git a/spec/models/operations/feature_flags/user_list_spec.rb b/spec/models/operations/feature_flags/user_list_spec.rb
index 020416aa7bc..3a48d3389a3 100644
--- a/spec/models/operations/feature_flags/user_list_spec.rb
+++ b/spec/models/operations/feature_flags/user_list_spec.rb
@@ -92,6 +92,25 @@ RSpec.describe Operations::FeatureFlags::UserList do
end
end
+ describe '.for_name_like' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user_list_one) { create(:operations_feature_flag_user_list, project: project, name: 'one') }
+ let_it_be(:user_list_two) { create(:operations_feature_flag_user_list, project: project, name: 'list_two') }
+ let_it_be(:user_list_three) { create(:operations_feature_flag_user_list, project: project, name: 'list_three') }
+
+ it 'returns a found name' do
+ lists = project.operations_feature_flags_user_lists.for_name_like('list')
+
+ expect(lists).to contain_exactly(user_list_two, user_list_three)
+ end
+
+ it 'returns an empty array when no lists match the query' do
+ lists = project.operations_feature_flags_user_lists.for_name_like('no match')
+
+ expect(lists).to be_empty
+ end
+ end
+
it_behaves_like 'AtomicInternalId' do
let(:internal_id_attribute) { :iid }
let(:instance) { build(:operations_feature_flag_user_list) }
diff --git a/spec/models/packages/build_info_spec.rb b/spec/models/packages/build_info_spec.rb
new file mode 100644
index 00000000000..a4369c56fe2
--- /dev/null
+++ b/spec/models/packages/build_info_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::BuildInfo, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:package) }
+ it { is_expected.to belong_to(:pipeline) }
+ end
+end
diff --git a/spec/models/packages/package_file_build_info_spec.rb b/spec/models/packages/package_file_build_info_spec.rb
new file mode 100644
index 00000000000..18d6e720bf8
--- /dev/null
+++ b/spec/models/packages/package_file_build_info_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::PackageFileBuildInfo, type: :model do
+ describe 'relationships' do
+ it { is_expected.to belong_to(:package_file) }
+ it { is_expected.to belong_to(:pipeline) }
+ end
+end
diff --git a/spec/models/packages/package_file_spec.rb b/spec/models/packages/package_file_spec.rb
index 6b992dbc2a5..ef09fb037e9 100644
--- a/spec/models/packages/package_file_spec.rb
+++ b/spec/models/packages/package_file_spec.rb
@@ -5,6 +5,8 @@ RSpec.describe Packages::PackageFile, type: :model do
describe 'relationships' do
it { is_expected.to belong_to(:package) }
it { is_expected.to have_one(:conan_file_metadatum) }
+ it { is_expected.to have_many(:package_file_build_infos).inverse_of(:package_file) }
+ it { is_expected.to have_many(:pipelines).through(:package_file_build_infos) }
end
describe 'validations' do
diff --git a/spec/models/packages/package_spec.rb b/spec/models/packages/package_spec.rb
index 41a731b87e9..705a1991845 100644
--- a/spec/models/packages/package_spec.rb
+++ b/spec/models/packages/package_spec.rb
@@ -10,6 +10,8 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.to have_many(:package_files).dependent(:destroy) }
it { is_expected.to have_many(:dependency_links).inverse_of(:package) }
it { is_expected.to have_many(:tags).inverse_of(:package) }
+ it { is_expected.to have_many(:build_infos).inverse_of(:package) }
+ it { is_expected.to have_many(:pipelines).through(:build_infos) }
it { is_expected.to have_one(:conan_metadatum).inverse_of(:package) }
it { is_expected.to have_one(:maven_metadatum).inverse_of(:package) }
it { is_expected.to have_one(:nuget_metadatum).inverse_of(:package) }
@@ -171,6 +173,13 @@ RSpec.describe Packages::Package, type: :model do
it { is_expected.not_to allow_value('%2e%2e%2f1.2.3').for(:version) }
end
+ context 'composer package' do
+ it_behaves_like 'validating version to be SemVer compliant for', :composer_package
+
+ it { is_expected.to allow_value('dev-master').for(:version) }
+ it { is_expected.to allow_value('2.x-dev').for(:version) }
+ end
+
context 'maven package' do
subject { build_stubbed(:maven_package) }
@@ -573,7 +582,7 @@ RSpec.describe Packages::Package, type: :model do
end
describe '#pipeline' do
- let_it_be(:package) { create(:maven_package) }
+ let_it_be_with_refind(:package) { create(:maven_package) }
context 'package without pipeline' do
it 'returns nil if there is no pipeline' do
@@ -585,7 +594,7 @@ RSpec.describe Packages::Package, type: :model do
let_it_be(:pipeline) { create(:ci_pipeline) }
before do
- package.create_build_info!(pipeline: pipeline)
+ package.build_infos.create!(pipeline: pipeline)
end
it 'returns the pipeline' do
@@ -630,4 +639,23 @@ RSpec.describe Packages::Package, type: :model do
end
end
end
+
+ describe '#original_build_info' do
+ let_it_be_with_refind(:package) { create(:npm_package) }
+
+ context 'without build_infos' do
+ it 'returns nil' do
+ expect(package.original_build_info).to be_nil
+ end
+ end
+
+ context 'with build_infos' do
+ let_it_be(:first_build_info) { create(:package_build_info, :with_pipeline, package: package) }
+ let_it_be(:second_build_info) { create(:package_build_info, :with_pipeline, package: package) }
+
+ it 'returns the first build info' do
+ expect(package.original_build_info).to eq(first_build_info)
+ end
+ end
+ end
end
diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb
index cb1938a0113..f8ebc237577 100644
--- a/spec/models/pages/lookup_path_spec.rb
+++ b/spec/models/pages/lookup_path_spec.rb
@@ -3,15 +3,14 @@
require 'spec_helper'
RSpec.describe Pages::LookupPath do
- let_it_be(:project) do
- create(:project, :pages_private, pages_https_only: true)
- end
+ let(:project) { create(:project, :pages_private, pages_https_only: true) }
subject(:lookup_path) { described_class.new(project) }
before do
stub_pages_setting(access_control: true, external_https: ["1.1.1.1:443"])
stub_artifacts_object_storage
+ stub_pages_object_storage(::Pages::DeploymentUploader)
end
describe '#project_id' do
@@ -47,18 +46,78 @@ RSpec.describe Pages::LookupPath do
end
describe '#source' do
- shared_examples 'uses disk storage' do
- it 'sets the source type to "file"' do
- expect(lookup_path.source[:type]).to eq('file')
- end
+ let(:source) { lookup_path.source }
- it 'sets the source path to the project full path suffixed with "public/' do
- expect(lookup_path.source[:path]).to eq(project.full_path + "/public/")
+ shared_examples 'uses disk storage' do
+ it 'uses disk storage', :aggregate_failures do
+ expect(source[:type]).to eq('file')
+ expect(source[:path]).to eq(project.full_path + "/public/")
end
end
include_examples 'uses disk storage'
+ context 'when there is pages deployment' do
+ let(:deployment) { create(:pages_deployment, project: project) }
+
+ before do
+ project.mark_pages_as_deployed
+ project.pages_metadatum.update!(pages_deployment: deployment)
+ end
+
+ it 'uses deployment from object storage' do
+ Timecop.freeze do
+ expect(source).to(
+ eq({
+ type: 'zip',
+ path: deployment.file.url(expire_at: 1.day.from_now),
+ global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
+ })
+ )
+ end
+ end
+
+ context 'when deployment is in the local storage' do
+ before do
+ deployment.file.migrate!(::ObjectStorage::Store::LOCAL)
+ end
+
+ it 'uses file protocol' do
+ Timecop.freeze do
+ expect(source).to(
+ eq({
+ type: 'zip',
+ path: 'file://' + deployment.file.path,
+ global_id: "gid://gitlab/PagesDeployment/#{deployment.id}",
+ sha256: deployment.file_sha256,
+ file_size: deployment.size,
+ file_count: deployment.file_count
+ })
+ )
+ end
+ end
+
+ context 'when pages_serve_with_zip_file_protocol feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_serve_with_zip_file_protocol: false)
+ end
+
+ include_examples 'uses disk storage'
+ end
+ end
+
+ context 'when pages_serve_from_deployments feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_serve_from_deployments: false)
+ end
+
+ include_examples 'uses disk storage'
+ end
+ end
+
context 'when artifact_id from build job is present in pages metadata' do
let(:artifacts_archive) { create(:ci_job_artifact, :zip, :remote_store, project: project) }
@@ -66,26 +125,51 @@ RSpec.describe Pages::LookupPath do
project.mark_pages_as_deployed(artifacts_archive: artifacts_archive)
end
- it 'sets the source type to "zip"' do
- expect(lookup_path.source[:type]).to eq('zip')
- end
-
- it 'sets the source path to the artifacts archive URL' do
+ it 'uses artifacts object storage' do
Timecop.freeze do
- expect(lookup_path.source[:path]).to eq(artifacts_archive.file.url(expire_at: 1.day.from_now))
- expect(lookup_path.source[:path]).to include("Expires=86400")
+ expect(source).to(
+ eq({
+ type: 'zip',
+ path: artifacts_archive.file.url(expire_at: 1.day.from_now),
+ global_id: "gid://gitlab/Ci::JobArtifact/#{artifacts_archive.id}",
+ sha256: artifacts_archive.file_sha256,
+ file_size: artifacts_archive.size,
+ file_count: nil
+ })
+ )
end
end
context 'when artifact is not uploaded to object storage' do
let(:artifacts_archive) { create(:ci_job_artifact, :zip) }
- include_examples 'uses disk storage'
+ it 'uses file protocol', :aggregate_failures do
+ Timecop.freeze do
+ expect(source).to(
+ eq({
+ type: 'zip',
+ path: 'file://' + artifacts_archive.file.path,
+ global_id: "gid://gitlab/Ci::JobArtifact/#{artifacts_archive.id}",
+ sha256: artifacts_archive.file_sha256,
+ file_size: artifacts_archive.size,
+ file_count: nil
+ })
+ )
+ end
+ end
+
+ context 'when pages_serve_with_zip_file_protocol feature flag is disabled' do
+ before do
+ stub_feature_flags(pages_serve_with_zip_file_protocol: false)
+ end
+
+ include_examples 'uses disk storage'
+ end
end
context 'when feature flag is disabled' do
before do
- stub_feature_flags(pages_artifacts_archive: false)
+ stub_feature_flags(pages_serve_from_artifacts_archive: false)
end
include_examples 'uses disk storage'
diff --git a/spec/models/pages_deployment_spec.rb b/spec/models/pages_deployment_spec.rb
index 5d26ade740e..e83cbc15004 100644
--- a/spec/models/pages_deployment_spec.rb
+++ b/spec/models/pages_deployment_spec.rb
@@ -10,8 +10,15 @@ RSpec.describe PagesDeployment do
describe 'validations' do
it { is_expected.to validate_presence_of(:file) }
+
it { is_expected.to validate_presence_of(:size) }
it { is_expected.to validate_numericality_of(:size).only_integer.is_greater_than(0) }
+
+ it { is_expected.to validate_presence_of(:file_count) }
+ it { is_expected.to validate_numericality_of(:file_count).only_integer.is_greater_than_or_equal_to(0) }
+
+ it { is_expected.to validate_presence_of(:file_sha256) }
+
it { is_expected.to validate_inclusion_of(:file_store).in_array(ObjectStorage::SUPPORTED_STORES) }
it 'is valid when created from the factory' do
@@ -20,14 +27,26 @@ RSpec.describe PagesDeployment do
end
describe 'default for file_store' do
+ let(:project) { create(:project) }
+ let(:deployment) do
+ filepath = Rails.root.join("spec/fixtures/pages.zip")
+
+ described_class.create!(
+ project: project,
+ file: fixture_file_upload(filepath),
+ file_sha256: Digest::SHA256.file(filepath).hexdigest,
+ file_count: 3
+ )
+ end
+
it 'uses local store when object storage is not enabled' do
- expect(build(:pages_deployment).file_store).to eq(ObjectStorage::Store::LOCAL)
+ expect(deployment.file_store).to eq(ObjectStorage::Store::LOCAL)
end
it 'uses remote store when object storage is enabled' do
stub_pages_object_storage(::Pages::DeploymentUploader)
- expect(build(:pages_deployment).file_store).to eq(ObjectStorage::Store::REMOTE)
+ expect(deployment.file_store).to eq(ObjectStorage::Store::REMOTE)
end
end
@@ -35,4 +54,17 @@ RSpec.describe PagesDeployment do
deployment = create(:pages_deployment)
expect(deployment.size).to eq(deployment.file.size)
end
+
+ describe '.older_than' do
+ it 'returns deployments with lower id' do
+ old_deployments = create_list(:pages_deployment, 2)
+
+ deployment = create(:pages_deployment)
+
+ # new deployment
+ create(:pages_deployment)
+
+ expect(PagesDeployment.older_than(deployment.id)).to eq(old_deployments)
+ end
+ end
end
diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb
index 9e80d0e0886..67ecbe13c1a 100644
--- a/spec/models/personal_access_token_spec.rb
+++ b/spec/models/personal_access_token_spec.rb
@@ -31,6 +31,18 @@ RSpec.describe PersonalAccessToken do
expect(described_class.for_user(user_1)).to contain_exactly(token_of_user_1)
end
end
+
+ describe '.for_users' do
+ it 'returns personal access tokens for the specified users only' do
+ user_1 = create(:user)
+ user_2 = create(:user)
+ token_of_user_1 = create(:personal_access_token, user: user_1)
+ token_of_user_2 = create(:personal_access_token, user: user_2)
+ create_list(:personal_access_token, 3)
+
+ expect(described_class.for_users([user_1, user_2])).to contain_exactly(token_of_user_1, token_of_user_2)
+ end
+ end
end
describe ".active?" do
diff --git a/spec/models/personal_snippet_spec.rb b/spec/models/personal_snippet_spec.rb
index 234f6e4b4b5..212605445ff 100644
--- a/spec/models/personal_snippet_spec.rb
+++ b/spec/models/personal_snippet_spec.rb
@@ -20,9 +20,8 @@ RSpec.describe PersonalSnippet do
it_behaves_like 'model with repository' do
let_it_be(:container) { create(:personal_snippet, :repository) }
let(:stubbed_container) { build_stubbed(:personal_snippet) }
- let(:expected_full_path) { "@snippets/#{container.id}" }
+ let(:expected_full_path) { "snippets/#{container.id}" }
let(:expected_web_url_path) { "-/snippets/#{container.id}" }
- let(:expected_repo_url_path) { "snippets/#{container.id}" }
end
describe '#parent_user' do
diff --git a/spec/models/project_snippet_spec.rb b/spec/models/project_snippet_spec.rb
index 3bcbf6b9e1b..3d1c87771f3 100644
--- a/spec/models/project_snippet_spec.rb
+++ b/spec/models/project_snippet_spec.rb
@@ -36,8 +36,7 @@ RSpec.describe ProjectSnippet do
it_behaves_like 'model with repository' do
let_it_be(:container) { create(:project_snippet, :repository) }
let(:stubbed_container) { build_stubbed(:project_snippet) }
- let(:expected_full_path) { "#{container.project.full_path}/@snippets/#{container.id}" }
+ 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) { "#{container.project.full_path}/snippets/#{container.id}" }
end
end
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 53a213891e9..c8b96963d5d 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -2,12 +2,14 @@
require 'spec_helper'
-RSpec.describe Project do
+RSpec.describe Project, factory_default: :keep do
include ProjectForksHelper
include GitHelpers
include ExternalAuthorizationServiceHelpers
using RSpec::Parameterized::TableSyntax
+ let_it_be(:namespace) { create_default(:namespace) }
+
it_behaves_like 'having unique enum values'
describe 'associations' do
@@ -3003,14 +3005,23 @@ RSpec.describe Project do
describe '#set_repository_read_only!' do
let(:project) { create(:project) }
- it 'returns true when there is no existing git transfer in progress' do
- expect(project.set_repository_read_only!).to be_truthy
+ it 'makes the repository read-only' do
+ expect { project.set_repository_read_only! }
+ .to change(project, :repository_read_only?)
+ .from(false)
+ .to(true)
end
- it 'returns false when there is an existing git transfer in progress' do
+ it 'raises an error if the project is already read-only' do
+ project.set_repository_read_only!
+
+ expect { project.set_repository_read_only! }.to raise_error(described_class::RepositoryReadOnlyError, /already read-only/)
+ end
+
+ it 'raises an error when there is an existing git transfer in progress' do
allow(project).to receive(:git_transfer_in_progress?) { true }
- expect(project.set_repository_read_only!).to be_falsey
+ expect { project.set_repository_read_only! }.to raise_error(described_class::RepositoryReadOnlyError, /in progress/)
end
end
@@ -3657,7 +3668,7 @@ RSpec.describe Project do
let(:project) { create(:project) }
before do
- project.namespace_id = 7
+ project.namespace_id = project.namespace_id + 1
end
it { expect(project.parent_changed?).to be_truthy }
@@ -3985,8 +3996,16 @@ RSpec.describe Project do
context 'when feature is private' do
let(:project) { create(:project, :public, :merge_requests_private) }
- it 'returns projects with the project feature private' do
- is_expected.to include(project)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns projects with the project feature private' do
+ is_expected.to include(project)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'does not return projects with the project feature private' do
+ is_expected.not_to include(project)
+ end
end
end
end
@@ -4009,7 +4028,7 @@ RSpec.describe Project do
end
end
- describe '.filter_by_feature_visibility', :enable_admin_mode do
+ describe '.filter_by_feature_visibility' do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
@@ -4021,12 +4040,13 @@ RSpec.describe Project do
context 'reporter level access' do
let(:feature) { MergeRequest }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_reporter_feature_access
end
with_them do
it "respects visibility" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
expected_objects = expected_count == 1 ? [project] : []
@@ -4041,12 +4061,13 @@ RSpec.describe Project do
context 'issues' do
let(:feature) { Issue }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_guest_feature_access
end
with_them do
it "respects visibility" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
expected_objects = expected_count == 1 ? [project] : []
@@ -4061,12 +4082,13 @@ RSpec.describe Project do
context 'wiki' do
let(:feature) { :wiki }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_guest_feature_access
end
with_them do
it "respects visibility" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
expected_objects = expected_count == 1 ? [project] : []
@@ -4081,12 +4103,13 @@ RSpec.describe Project do
context 'code' do
let(:feature) { :repository }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_guest_feature_access_and_non_private_project_only
end
with_them do
it "respects visibility" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
expected_objects = expected_count == 1 ? [project] : []
@@ -4208,6 +4231,27 @@ RSpec.describe Project do
expect { project.destroy }.not_to raise_error
end
+
+ context 'when there is an old pages deployment' do
+ let!(:old_deployment_from_another_project) { create(:pages_deployment) }
+ let!(:old_deployment) { create(:pages_deployment, project: project) }
+
+ it 'schedules a destruction of pages deployments' do
+ expect(DestroyPagesDeploymentsWorker).to(
+ receive(:perform_async).with(project.id)
+ )
+
+ project.remove_pages
+ end
+
+ it 'removes pages deployments', :sidekiq_inline do
+ expect do
+ project.remove_pages
+ end.to change { PagesDeployment.count }.by(-1)
+
+ expect(PagesDeployment.find_by_id(old_deployment.id)).to be_nil
+ end
+ end
end
describe '#remove_export' do
@@ -5507,15 +5551,16 @@ RSpec.describe Project do
end
describe '#find_or_initialize_services' do
- it 'returns only enabled services' do
+ before do
allow(Service).to receive(:available_services_names).and_return(%w[prometheus pushover teamcity])
- allow(Service).to receive(:project_specific_services_names).and_return(%w[asana])
allow(subject).to receive(:disabled_services).and_return(%w[prometheus])
+ end
+ it 'returns only enabled services' do
services = subject.find_or_initialize_services
- expect(services.count).to eq(3)
- expect(services.map(&:title)).to eq(['Asana', 'JetBrains TeamCity CI', 'Pushover'])
+ expect(services.count).to eq(2)
+ expect(services.map(&:title)).to eq(['JetBrains TeamCity CI', 'Pushover'])
end
end
@@ -5895,6 +5940,26 @@ RSpec.describe Project do
end
end
+ describe '#update_pages_deployment!' do
+ let(:project) { create(:project) }
+ let(:deployment) { create(:pages_deployment, project: project) }
+
+ it "creates new metadata record if none exists yet and sets deployment" do
+ project.pages_metadatum.destroy!
+ project.reload
+
+ project.update_pages_deployment!(deployment)
+
+ expect(project.pages_metadatum.pages_deployment).to eq(deployment)
+ end
+
+ it "updates the existing metadara record with deployment" do
+ expect do
+ project.update_pages_deployment!(deployment)
+ end.to change { project.pages_metadatum.reload.pages_deployment }.from(nil).to(deployment)
+ end
+ end
+
describe '#has_pool_repsitory?' do
it 'returns false when it does not have a pool repository' do
subject = create(:project, :repository)
diff --git a/spec/models/project_statistics_spec.rb b/spec/models/project_statistics_spec.rb
index 9f40dbb3401..2d283766edb 100644
--- a/spec/models/project_statistics_spec.rb
+++ b/spec/models/project_statistics_spec.rb
@@ -34,7 +34,8 @@ RSpec.describe ProjectStatistics do
lfs_objects_size: 2.exabytes,
build_artifacts_size: 1.exabyte,
snippets_size: 1.exabyte,
- pipeline_artifacts_size: 1.exabyte - 1
+ pipeline_artifacts_size: 512.petabytes - 1,
+ uploads_size: 512.petabytes
)
statistics.reload
@@ -46,7 +47,8 @@ RSpec.describe ProjectStatistics do
expect(statistics.build_artifacts_size).to eq(1.exabyte)
expect(statistics.storage_size).to eq(8.exabytes - 1)
expect(statistics.snippets_size).to eq(1.exabyte)
- expect(statistics.pipeline_artifacts_size).to eq(1.exabyte - 1)
+ expect(statistics.pipeline_artifacts_size).to eq(512.petabytes - 1)
+ expect(statistics.uploads_size).to eq(512.petabytes)
end
end
@@ -57,6 +59,7 @@ RSpec.describe ProjectStatistics do
statistics.lfs_objects_size = 3
statistics.build_artifacts_size = 4
statistics.snippets_size = 5
+ statistics.uploads_size = 3
expect(statistics.total_repository_size).to eq 5
end
@@ -98,6 +101,7 @@ RSpec.describe ProjectStatistics do
allow(statistics).to receive(:update_lfs_objects_size)
allow(statistics).to receive(:update_snippets_size)
allow(statistics).to receive(:update_storage_size)
+ allow(statistics).to receive(:update_uploads_size)
end
context "without arguments" do
@@ -111,6 +115,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_wiki_size)
expect(statistics).to have_received(:update_lfs_objects_size)
expect(statistics).to have_received(:update_snippets_size)
+ expect(statistics).to have_received(:update_uploads_size)
end
end
@@ -125,6 +130,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).not_to have_received(:update_repository_size)
expect(statistics).not_to have_received(:update_wiki_size)
expect(statistics).not_to have_received(:update_snippets_size)
+ expect(statistics).not_to have_received(:update_uploads_size)
end
end
@@ -139,10 +145,12 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_repository_size)
expect(statistics).to have_received(:update_wiki_size)
expect(statistics).to have_received(:update_snippets_size)
+ expect(statistics).to have_received(:update_uploads_size)
expect(statistics.repository_size).to eq(0)
expect(statistics.commit_count).to eq(0)
expect(statistics.wiki_size).to eq(0)
expect(statistics.snippets_size).to eq(0)
+ expect(statistics.uploads_size).to eq(0)
end
end
@@ -163,10 +171,12 @@ RSpec.describe ProjectStatistics do
expect(statistics).to have_received(:update_repository_size)
expect(statistics).to have_received(:update_wiki_size)
expect(statistics).to have_received(:update_snippets_size)
+ expect(statistics).to have_received(:update_uploads_size)
expect(statistics.repository_size).to eq(0)
expect(statistics.commit_count).to eq(0)
expect(statistics.wiki_size).to eq(0)
expect(statistics.snippets_size).to eq(0)
+ expect(statistics.uploads_size).to eq(0)
end
end
@@ -211,6 +221,7 @@ RSpec.describe ProjectStatistics do
expect(statistics).not_to receive(:update_wiki_size)
expect(statistics).not_to receive(:update_lfs_objects_size)
expect(statistics).not_to receive(:update_snippets_size)
+ expect(statistics).not_to receive(:update_uploads_size)
expect(statistics).not_to receive(:save!)
expect(Namespaces::ScheduleAggregationWorker)
.not_to receive(:perform_async)
@@ -295,6 +306,26 @@ RSpec.describe ProjectStatistics do
end
end
+ describe '#update_uploads_size' do
+ let!(:upload1) { create(:upload, model: project, size: 1.megabyte) }
+ let!(:upload2) { create(:upload, model: project, size: 2.megabytes) }
+
+ it 'stores the size of related uploaded files' do
+ expect(statistics.update_uploads_size).to eq(3.megabytes)
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ statistics.update_columns(uploads_size: 0)
+ stub_feature_flags(count_uploads_size_in_storage_stats: false)
+ end
+
+ it 'does not store the size of related uploaded files' do
+ expect(statistics.update_uploads_size).to eq(0)
+ end
+ end
+ end
+
describe '#update_storage_size' do
it "sums all storage counters" do
statistics.update!(
@@ -302,12 +333,13 @@ RSpec.describe ProjectStatistics do
wiki_size: 4,
lfs_objects_size: 3,
snippets_size: 2,
- pipeline_artifacts_size: 3
+ pipeline_artifacts_size: 3,
+ uploads_size: 5
)
statistics.reload
- expect(statistics.storage_size).to eq 14
+ expect(statistics.storage_size).to eq 19
end
it 'works during wiki_size backfill' do
diff --git a/spec/models/protected_branch/push_access_level_spec.rb b/spec/models/protected_branch/push_access_level_spec.rb
index 77fe9814c86..0aba51ea567 100644
--- a/spec/models/protected_branch/push_access_level_spec.rb
+++ b/spec/models/protected_branch/push_access_level_spec.rb
@@ -4,4 +4,34 @@ require 'spec_helper'
RSpec.describe ProtectedBranch::PushAccessLevel do
it { is_expected.to validate_inclusion_of(:access_level).in_array([Gitlab::Access::MAINTAINER, Gitlab::Access::DEVELOPER, Gitlab::Access::NO_ACCESS]) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:deploy_key) }
+ end
+
+ describe 'validations' do
+ it 'is not valid when a record exists with the same access level' do
+ protected_branch = create(:protected_branch)
+ create(:protected_branch_push_access_level, protected_branch: protected_branch)
+ level = build(:protected_branch_push_access_level, protected_branch: protected_branch)
+
+ expect(level).to be_invalid
+ end
+
+ it 'is not valid when a record exists with the same access level' do
+ protected_branch = create(:protected_branch)
+ deploy_key = create(:deploy_key, projects: [protected_branch.project])
+ create(:protected_branch_push_access_level, protected_branch: protected_branch, deploy_key: deploy_key)
+ level = build(:protected_branch_push_access_level, protected_branch: protected_branch, deploy_key: deploy_key)
+
+ expect(level).to be_invalid
+ end
+
+ it 'checks that a deploy key is enabled for the same project as the protected branch\'s' do
+ level = build(:protected_branch_push_access_level, deploy_key: create(:deploy_key))
+
+ expect { level.save! }.to raise_error
+ expect(level.errors.full_messages).to contain_exactly('Deploy key is not enabled for this project')
+ end
+ end
end
diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb
index 0f1637016d6..eb81db95cd3 100644
--- a/spec/models/route_spec.rb
+++ b/spec/models/route_spec.rb
@@ -62,6 +62,15 @@ RSpec.describe Route do
end
end
+ describe '.for_routable_type' do
+ let!(:nested_group) { create(:group, path: 'foo', name: 'foo', parent: group) }
+ let!(:project) { create(:project, path: 'other-project') }
+
+ it 'returns correct routes' do
+ expect(described_class.for_routable_type(Project.name)).to match_array([project.route])
+ end
+ end
+
describe '#rename_descendants' do
let!(:nested_group) { create(:group, path: 'test', name: 'test', parent: group) }
let!(:deep_nested_group) { create(:group, path: 'foo', name: 'foo', parent: nested_group) }
diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb
index db3cf19a03f..402c1a3d19b 100644
--- a/spec/models/service_spec.rb
+++ b/spec/models/service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Service do
+ using RSpec::Parameterized::TableSyntax
+
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
@@ -15,8 +17,6 @@ RSpec.describe Service do
end
describe 'validations' do
- using RSpec::Parameterized::TableSyntax
-
it { is_expected.to validate_presence_of(:type) }
where(:project_id, :group_id, :template, :instance, :valid) do
@@ -208,27 +208,27 @@ RSpec.describe Service do
end
end
- describe '.find_or_initialize_integration' do
+ describe '.find_or_initialize_non_project_specific_integration' do
let!(:service1) { create(:jira_service, project_id: nil, group_id: group.id) }
let!(:service2) { create(:jira_service) }
it 'returns the right service' do
- expect(Service.find_or_initialize_integration('jira', group_id: group)).to eq(service1)
+ expect(Service.find_or_initialize_non_project_specific_integration('jira', group_id: group)).to eq(service1)
end
it 'does not create a new service' do
- expect { Service.find_or_initialize_integration('redmine', group_id: group) }.not_to change { Service.count }
+ expect { Service.find_or_initialize_non_project_specific_integration('redmine', group_id: group) }.not_to change { Service.count }
end
end
- describe '.find_or_initialize_all' do
+ describe '.find_or_initialize_all_non_project_specific' do
shared_examples 'service instances' do
it 'returns the available service instances' do
- expect(Service.find_or_initialize_all(Service.for_instance).pluck(:type)).to match_array(Service.available_services_types)
+ expect(Service.find_or_initialize_all_non_project_specific(Service.for_instance).pluck(:type)).to match_array(Service.available_services_types(include_project_specific: false))
end
it 'does not create service instances' do
- expect { Service.find_or_initialize_all(Service.for_instance) }.not_to change { Service.count }
+ expect { Service.find_or_initialize_all_non_project_specific(Service.for_instance) }.not_to change { Service.count }
end
end
@@ -237,7 +237,7 @@ RSpec.describe Service do
context 'with all existing instances' do
before do
Service.insert_all(
- Service.available_services_types.map { |type| { instance: true, type: type } }
+ Service.available_services_types(include_project_specific: false).map { |type| { instance: true, type: type } }
)
end
@@ -265,13 +265,13 @@ RSpec.describe Service do
describe 'template' do
shared_examples 'retrieves service templates' do
it 'returns the available service templates' do
- expect(Service.find_or_create_templates.pluck(:type)).to match_array(Service.available_services_types)
+ expect(Service.find_or_create_templates.pluck(:type)).to match_array(Service.available_services_types(include_project_specific: false))
end
end
describe '.find_or_create_templates' do
it 'creates service templates' do
- expect { Service.find_or_create_templates }.to change { Service.count }.from(0).to(Service.available_services_names.size)
+ expect { Service.find_or_create_templates }.to change { Service.count }.from(0).to(Service.available_services_names(include_project_specific: false).size)
end
it_behaves_like 'retrieves service templates'
@@ -279,7 +279,7 @@ RSpec.describe Service do
context 'with all existing templates' do
before do
Service.insert_all(
- Service.available_services_types.map { |type| { template: true, type: type } }
+ Service.available_services_types(include_project_specific: false).map { |type| { template: true, type: type } }
)
end
@@ -305,7 +305,7 @@ RSpec.describe Service do
end
it 'creates the rest of the service templates' do
- expect { Service.find_or_create_templates }.to change { Service.count }.from(1).to(Service.available_services_names.size)
+ expect { Service.find_or_create_templates }.to change { Service.count }.from(1).to(Service.available_services_names(include_project_specific: false).size)
end
it_behaves_like 'retrieves service templates'
@@ -599,6 +599,23 @@ RSpec.describe Service do
end
end
+ describe '.inherited_descendants_from_self_or_ancestors_from' do
+ let_it_be(:subgroup1) { create(:group, parent: group) }
+ let_it_be(:subgroup2) { create(:group, parent: group) }
+ let_it_be(:project1) { create(:project, group: subgroup1) }
+ let_it_be(:project2) { create(:project, group: subgroup2) }
+ let_it_be(:group_integration) { create(:prometheus_service, group: group, project: nil) }
+ let_it_be(:subgroup_integration1) { create(:prometheus_service, group: subgroup1, project: nil, inherit_from_id: group_integration.id) }
+ let_it_be(:subgroup_integration2) { create(:prometheus_service, group: subgroup2, project: nil) }
+ let_it_be(:project_integration1) { create(:prometheus_service, group: nil, project: project1, inherit_from_id: group_integration.id) }
+ let_it_be(:project_integration2) { create(:prometheus_service, group: nil, project: project2, inherit_from_id: subgroup_integration2.id) }
+
+ it 'returns the groups and projects inheriting from integration ancestors', :aggregate_failures do
+ expect(described_class.inherited_descendants_from_self_or_ancestors_from(group_integration)).to eq([subgroup_integration1, project_integration1])
+ expect(described_class.inherited_descendants_from_self_or_ancestors_from(subgroup_integration2)).to eq([project_integration2])
+ end
+ end
+
describe "{property}_changed?" do
let(:service) do
BambooService.create(
@@ -826,5 +843,78 @@ RSpec.describe Service do
service.log_error(test_message, additional_argument: 'some argument')
end
+
+ context 'when project is nil' do
+ let(:project) { nil }
+ let(:arguments) do
+ {
+ service_class: service.class.name,
+ project_path: nil,
+ project_id: nil,
+ message: test_message,
+ additional_argument: 'some argument'
+ }
+ end
+
+ it 'logs info messages using json logger' do
+ expect(Gitlab::JsonLogger).to receive(:info).with(arguments)
+
+ service.log_info(test_message, additional_argument: 'some argument')
+ end
+ end
+ end
+
+ describe '#external_issue_tracker?' do
+ where(:category, :active, :result) do
+ :issue_tracker | true | true
+ :issue_tracker | false | false
+ :common | true | false
+ end
+
+ with_them do
+ it 'returns the right result' do
+ expect(build(:service, category: category, active: active).external_issue_tracker?).to eq(result)
+ end
+ end
+ end
+
+ describe '#external_wiki?' do
+ where(:type, :active, :result) do
+ 'ExternalWikiService' | true | true
+ 'ExternalWikiService' | false | false
+ 'SlackService' | true | false
+ end
+
+ with_them do
+ it 'returns the right result' do
+ expect(build(:service, type: type, active: active).external_wiki?).to eq(result)
+ end
+ end
+ end
+
+ describe '.available_services_names' do
+ it 'calls the right methods' do
+ expect(described_class).to receive(:services_names).and_call_original
+ expect(described_class).to receive(:dev_services_names).and_call_original
+ expect(described_class).to receive(:project_specific_services_names).and_call_original
+
+ described_class.available_services_names
+ end
+
+ it 'does not call project_specific_services_names with include_project_specific false' do
+ expect(described_class).to receive(:services_names).and_call_original
+ expect(described_class).to receive(:dev_services_names).and_call_original
+ expect(described_class).not_to receive(:project_specific_services_names)
+
+ described_class.available_services_names(include_project_specific: false)
+ end
+
+ it 'does not call dev_services_names with include_dev false' do
+ expect(described_class).to receive(:services_names).and_call_original
+ expect(described_class).not_to receive(:dev_services_names)
+ expect(described_class).to receive(:project_specific_services_names).and_call_original
+
+ described_class.available_services_names(include_dev: false)
+ end
end
end
diff --git a/spec/models/terraform/state_spec.rb b/spec/models/terraform/state_spec.rb
index 608c5bdf03a..ca8fe4cca3b 100644
--- a/spec/models/terraform/state_spec.rb
+++ b/spec/models/terraform/state_spec.rb
@@ -97,10 +97,11 @@ RSpec.describe Terraform::State do
end
describe '#update_file!' do
- let(:version) { 3 }
- let(:data) { Hash[terraform_version: '0.12.21'].to_json }
+ let_it_be(:build) { create(:ci_build) }
+ let_it_be(:version) { 3 }
+ let_it_be(:data) { Hash[terraform_version: '0.12.21'].to_json }
- subject { terraform_state.update_file!(CarrierWaveStringFile.new(data), version: version) }
+ subject { terraform_state.update_file!(CarrierWaveStringFile.new(data), version: version, build: build) }
context 'versioning is enabled' do
let(:terraform_state) { create(:terraform_state) }
@@ -109,6 +110,7 @@ RSpec.describe Terraform::State do
expect { subject }.to change { Terraform::StateVersion.count }
expect(terraform_state.latest_version.version).to eq(version)
+ expect(terraform_state.latest_version.build).to eq(build)
expect(terraform_state.latest_version.file.read).to eq(data)
end
end
diff --git a/spec/models/terraform/state_version_spec.rb b/spec/models/terraform/state_version_spec.rb
index cc5ea87159d..97ac77d5e7b 100644
--- a/spec/models/terraform/state_version_spec.rb
+++ b/spec/models/terraform/state_version_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Terraform::StateVersion do
it { is_expected.to belong_to(:terraform_state).required }
it { is_expected.to belong_to(:created_by_user).class_name('User').optional }
+ it { is_expected.to belong_to(:build).class_name('Ci::Build').optional }
describe 'scopes' do
describe '.ordered_by_version_desc' do
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 64bff5d00aa..19a6a3ce3c4 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -319,7 +319,7 @@ RSpec.describe User do
expect(subject).to validate_presence_of(:username)
end
- it 'rejects blacklisted names' do
+ it 'rejects denied names' do
user = build(:user, username: 'dashboard')
expect(user).not_to be_valid
@@ -442,9 +442,9 @@ RSpec.describe User do
end
describe 'email' do
- context 'when no signup domains whitelisted' do
+ context 'when no signup domains allowed' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return([])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return([])
end
it 'accepts any email' do
@@ -455,7 +455,7 @@ RSpec.describe User do
context 'bad regex' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['([a-zA-Z0-9]+)+\.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return(['([a-zA-Z0-9]+)+\.com'])
end
it 'does not hang on evil input' do
@@ -467,9 +467,9 @@ RSpec.describe User do
end
end
- context 'when a signup domain is whitelisted and subdomains are allowed' do
+ context 'when a signup domain is allowed and subdomains are allowed' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['example.com', '*.example.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return(['example.com', '*.example.com'])
end
it 'accepts info@example.com' do
@@ -488,9 +488,9 @@ RSpec.describe User do
end
end
- context 'when a signup domain is whitelisted and subdomains are not allowed' do
+ context 'when a signup domain is allowed and subdomains are not allowed' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['example.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return(['example.com'])
end
it 'accepts info@example.com' do
@@ -514,15 +514,15 @@ RSpec.describe User do
end
end
- context 'domain blacklist' do
+ context 'domain denylist' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_blacklist_enabled?).and_return(true)
- allow_any_instance_of(ApplicationSetting).to receive(:domain_blacklist).and_return(['example.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_denylist_enabled?).and_return(true)
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_denylist).and_return(['example.com'])
end
context 'bad regex' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_blacklist).and_return(['([a-zA-Z0-9]+)+\.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_denylist).and_return(['([a-zA-Z0-9]+)+\.com'])
end
it 'does not hang on evil input' do
@@ -534,7 +534,7 @@ RSpec.describe User do
end
end
- context 'when a signup domain is blacklisted' do
+ context 'when a signup domain is denied' do
it 'accepts info@test.com' do
user = build(:user, email: 'info@test.com')
expect(user).to be_valid
@@ -551,13 +551,13 @@ RSpec.describe User do
end
end
- context 'when a signup domain is blacklisted but a wildcard subdomain is allowed' do
+ context 'when a signup domain is denied but a wildcard subdomain is allowed' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_blacklist).and_return(['test.example.com'])
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['*.example.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_denylist).and_return(['test.example.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return(['*.example.com'])
end
- it 'gives priority to whitelist and allow info@test.example.com' do
+ it 'gives priority to allowlist and allow info@test.example.com' do
user = build(:user, email: 'info@test.example.com')
expect(user).to be_valid
end
@@ -565,7 +565,7 @@ RSpec.describe User do
context 'with both lists containing a domain' do
before do
- allow_any_instance_of(ApplicationSetting).to receive(:domain_whitelist).and_return(['test.com'])
+ allow_any_instance_of(ApplicationSetting).to receive(:domain_allowlist).and_return(['test.com'])
end
it 'accepts info@test.com' do
@@ -1740,6 +1740,16 @@ RSpec.describe User do
end
end
+ describe '.instance_access_request_approvers_to_be_notified' do
+ let_it_be(:admin_list) { create_list(:user, 12, :admin, :with_sign_ins) }
+
+ it 'returns up to the ten most recently active instance admins' do
+ active_admins_in_recent_sign_in_desc_order = User.admins.active.order_recent_sign_in.limit(10)
+
+ expect(User.instance_access_request_approvers_to_be_notified).to eq(active_admins_in_recent_sign_in_desc_order)
+ end
+ end
+
describe '.filter_items' do
let(:user) { double }
@@ -2906,6 +2916,34 @@ RSpec.describe User do
subject { user.authorized_groups }
it { is_expected.to contain_exactly private_group, project_group }
+
+ context 'with shared memberships' do
+ let!(:shared_group) { create(:group) }
+ let!(:other_group) { create(:group) }
+
+ before do
+ create(:group_group_link, shared_group: shared_group, shared_with_group: private_group)
+ create(:group_group_link, shared_group: private_group, shared_with_group: other_group)
+ end
+
+ context 'when shared_group_membership_auth is enabled' do
+ before do
+ stub_feature_flags(shared_group_membership_auth: user)
+ end
+
+ it { is_expected.to include shared_group }
+ it { is_expected.not_to include other_group }
+ end
+
+ context 'when shared_group_membership_auth is disabled' do
+ before do
+ stub_feature_flags(shared_group_membership_auth: false)
+ end
+
+ it { is_expected.not_to include shared_group }
+ it { is_expected.not_to include other_group }
+ end
+ end
end
describe '#membership_groups' do
@@ -3637,9 +3675,9 @@ RSpec.describe User do
end
end
- context 'when a domain whitelist is in place' do
+ context 'when a domain allowlist is in place' do
before do
- stub_application_setting(domain_whitelist: ['gitlab.com'])
+ stub_application_setting(domain_allowlist: ['gitlab.com'])
end
it 'creates a ghost user' do
diff --git a/spec/policies/alert_management/http_integration_policy_spec.rb b/spec/policies/alert_management/http_integration_policy_spec.rb
new file mode 100644
index 00000000000..8e8348ad7f5
--- /dev/null
+++ b/spec/policies/alert_management/http_integration_policy_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegrationPolicy, :models do
+ let(:integration) { create(:alert_management_http_integration) }
+ let(:project) { integration.project }
+ let(:user) { create(:user) }
+
+ subject(:policy) { described_class.new(user, integration) }
+
+ describe 'rules' do
+ it { is_expected.to be_disallowed :admin_operations }
+
+ context 'when maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it { is_expected.to be_allowed :admin_operations }
+ end
+ end
+end
diff --git a/spec/policies/base_policy_spec.rb b/spec/policies/base_policy_spec.rb
index 103f2e9bc39..226660dc955 100644
--- a/spec/policies/base_policy_spec.rb
+++ b/spec/policies/base_policy_spec.rb
@@ -22,6 +22,34 @@ RSpec.describe BasePolicy do
end
end
+ shared_examples 'admin only access' do |policy|
+ let(:current_user) { build_stubbed(:user) }
+
+ subject { described_class.new(current_user, nil) }
+
+ it { is_expected.not_to be_allowed(policy) }
+
+ context 'for admins' do
+ let(:current_user) { build_stubbed(:admin) }
+
+ it 'allowed when in admin mode' do
+ enable_admin_mode!(current_user)
+
+ is_expected.to be_allowed(policy)
+ end
+
+ it 'prevented when not in admin mode' do
+ is_expected.not_to be_allowed(policy)
+ end
+ end
+
+ context 'for anonymous' do
+ let(:current_user) { nil }
+
+ it { is_expected.not_to be_allowed(policy) }
+ end
+ end
+
describe 'read cross project' do
let(:current_user) { build_stubbed(:user) }
let(:user) { build_stubbed(:user) }
@@ -41,51 +69,15 @@ RSpec.describe BasePolicy do
enable_external_authorization_service_check
end
- it { is_expected.not_to be_allowed(:read_cross_project) }
-
- context 'for admins' do
- let(:current_user) { build_stubbed(:admin) }
-
- subject { described_class.new(current_user, nil) }
-
- it 'allowed when in admin mode' do
- enable_admin_mode!(current_user)
-
- is_expected.to be_allowed(:read_cross_project)
- end
-
- it 'prevented when not in admin mode' do
- is_expected.not_to be_allowed(:read_cross_project)
- end
- end
-
- context 'for anonymous' do
- let(:current_user) { nil }
-
- it { is_expected.not_to be_allowed(:read_cross_project) }
- end
+ it_behaves_like 'admin only access', :read_cross_project
end
end
describe 'full private access' do
- let(:current_user) { build_stubbed(:user) }
-
- subject { described_class.new(current_user, nil) }
-
- it { is_expected.not_to be_allowed(:read_all_resources) }
-
- context 'for admins' do
- let(:current_user) { build_stubbed(:admin) }
-
- it 'allowed when in admin mode' do
- enable_admin_mode!(current_user)
-
- is_expected.to be_allowed(:read_all_resources)
- end
+ it_behaves_like 'admin only access', :read_all_resources
+ end
- it 'prevented when not in admin mode' do
- is_expected.not_to be_allowed(:read_all_resources)
- end
- end
+ describe 'change_repository_storage' do
+ it_behaves_like 'admin only access', :change_repository_storage
end
end
diff --git a/spec/policies/blob_policy_spec.rb b/spec/policies/blob_policy_spec.rb
index fc46b25f25c..daabcd844af 100644
--- a/spec/policies/blob_policy_spec.rb
+++ b/spec/policies/blob_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe BlobPolicy, :enable_admin_mode do
+RSpec.describe BlobPolicy do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
@@ -13,12 +13,13 @@ RSpec.describe BlobPolicy, :enable_admin_mode do
subject(:policy) { described_class.new(user, blob) }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_guest_feature_access_and_non_private_project_only
end
with_them do
it "grants permission" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
if expected_count == 1
diff --git a/spec/policies/group_member_policy_spec.rb b/spec/policies/group_member_policy_spec.rb
index 4215fa09301..9e58ea81ef3 100644
--- a/spec/policies/group_member_policy_spec.rb
+++ b/spec/policies/group_member_policy_spec.rb
@@ -42,6 +42,7 @@ RSpec.describe GroupMemberPolicy do
it do
expect_disallowed(:destroy_group_member)
expect_disallowed(:update_group_member)
+ expect_allowed(:read_group)
end
end
diff --git a/spec/policies/group_policy_spec.rb b/spec/policies/group_policy_spec.rb
index fecf5f3e4f8..7cded27e449 100644
--- a/spec/policies/group_policy_spec.rb
+++ b/spec/policies/group_policy_spec.rb
@@ -876,10 +876,30 @@ RSpec.describe GroupPolicy do
let(:deploy_token) { create(:deploy_token, :group, write_package_registry: true) }
it { is_expected.to be_allowed(:create_package) }
+ it { is_expected.to be_allowed(:read_package) }
it { is_expected.to be_allowed(:read_group) }
it { is_expected.to be_disallowed(:destroy_package) }
end
end
it_behaves_like 'Self-managed Core resource access tokens'
+
+ context 'support bot' do
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:current_user) { User.support_bot }
+
+ before do
+ allow(Gitlab::ServiceDesk).to receive(:supported?).and_return(true)
+ end
+
+ it { expect_disallowed(:read_label) }
+
+ context 'when group hierarchy has a project with service desk enabled' do
+ let_it_be(:subgroup) { create(:group, :private, parent: group)}
+ let_it_be(:project) { create(:project, group: subgroup, service_desk_enabled: true) }
+
+ it { expect_allowed(:read_label) }
+ it { expect(described_class.new(current_user, subgroup)).to be_allowed(:read_label) }
+ end
+ end
end
diff --git a/spec/policies/instance_metadata_policy_spec.rb b/spec/policies/instance_metadata_policy_spec.rb
new file mode 100644
index 00000000000..2c8e18483e6
--- /dev/null
+++ b/spec/policies/instance_metadata_policy_spec.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe InstanceMetadataPolicy do
+ subject { described_class.new(user, InstanceMetadata.new) }
+
+ context 'for any logged-in user' do
+ let(:user) { create(:user) }
+
+ specify { expect_allowed(:read_instance_metadata) }
+ end
+
+ context 'for anonymous users' do
+ let(:user) { nil }
+
+ specify { expect_disallowed(:read_instance_metadata) }
+ end
+end
diff --git a/spec/policies/issue_policy_spec.rb b/spec/policies/issue_policy_spec.rb
index e352b990159..76788ae2cb7 100644
--- a/spec/policies/issue_policy_spec.rb
+++ b/spec/policies/issue_policy_spec.rb
@@ -139,8 +139,13 @@ RSpec.describe IssuePolicy do
create(:project_group_link, group: group, project: project)
end
+ it 'does not allow guest to create todos' do
+ expect(permissions(nil, issue)).to be_allowed(:read_issue)
+ expect(permissions(nil, issue)).to be_disallowed(:create_todo)
+ end
+
it 'allows guests to read issues' do
- expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid)
+ expect(permissions(guest, issue)).to be_allowed(:read_issue, :read_issue_iid, :create_todo)
expect(permissions(guest, issue)).to be_disallowed(:update_issue, :admin_issue, :reopen_issue)
expect(permissions(guest, issue_no_assignee)).to be_allowed(:read_issue, :read_issue_iid)
diff --git a/spec/policies/merge_request_policy_spec.rb b/spec/policies/merge_request_policy_spec.rb
index 3a46d5b9226..744822f58d1 100644
--- a/spec/policies/merge_request_policy_spec.rb
+++ b/spec/policies/merge_request_policy_spec.rb
@@ -24,6 +24,7 @@ RSpec.describe MergeRequestPolicy do
mr_perms = %i[create_merge_request_in
create_merge_request_from
read_merge_request
+ create_todo
approve_merge_request
create_note].freeze
@@ -47,6 +48,18 @@ RSpec.describe MergeRequestPolicy do
end
end
+ context 'when merge request is public' do
+ context 'and user is anonymous' do
+ let(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: author) }
+
+ subject { permissions(nil, merge_request) }
+
+ it do
+ is_expected.to be_disallowed(:create_todo)
+ end
+ end
+ end
+
context 'when merge requests have been disabled' do
let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, author: author) }
diff --git a/spec/policies/note_policy_spec.rb b/spec/policies/note_policy_spec.rb
index a4cc3a1e9af..f6cd84f29ae 100644
--- a/spec/policies/note_policy_spec.rb
+++ b/spec/policies/note_policy_spec.rb
@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe NotePolicy do
- describe '#rules' do
+ describe '#rules', :aggregate_failures do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project) }
@@ -11,14 +11,15 @@ RSpec.describe NotePolicy do
let(:policy) { described_class.new(user, note) }
let(:note) { create(:note, noteable: noteable, author: user, project: project) }
+ shared_examples_for 'user cannot read or act on the note' do
+ specify do
+ expect(policy).to be_disallowed(:admin_note, :reposition_note, :resolve_note, :read_note, :award_emoji)
+ end
+ end
+
shared_examples_for 'a discussion with a private noteable' do
context 'when the note author can no longer see the noteable' do
- it 'can not edit nor read the note' do
- expect(policy).to be_disallowed(:admin_note)
- expect(policy).to be_disallowed(:resolve_note)
- expect(policy).to be_disallowed(:read_note)
- expect(policy).to be_disallowed(:award_emoji)
- end
+ it_behaves_like 'user cannot read or act on the note'
end
context 'when the note author can still see the noteable' do
@@ -28,6 +29,7 @@ RSpec.describe NotePolicy do
it 'can edit the note' do
expect(policy).to be_allowed(:admin_note)
+ expect(policy).to be_allowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
expect(policy).to be_allowed(:award_emoji)
@@ -35,6 +37,13 @@ RSpec.describe NotePolicy do
end
end
+ shared_examples_for 'a note on a public noteable' do
+ it 'can only read and award emoji on the note' do
+ expect(policy).to be_allowed(:read_note, :award_emoji)
+ expect(policy).to be_disallowed(:reposition_note, :admin_note, :resolve_note)
+ end
+ end
+
context 'when the noteable is a deleted commit' do
let(:commit) { nil }
let(:note) { create(:note_on_commit, commit_id: '12345678', author: user, project: project) }
@@ -42,6 +51,7 @@ RSpec.describe NotePolicy do
it 'allows to read' do
expect(policy).to be_allowed(:read_note)
expect(policy).to be_disallowed(:admin_note)
+ expect(policy).to be_disallowed(:reposition_note)
expect(policy).to be_disallowed(:resolve_note)
expect(policy).to be_disallowed(:award_emoji)
end
@@ -66,31 +76,60 @@ RSpec.describe NotePolicy do
end
end
+ context 'when the noteable is a Design' do
+ include DesignManagementTestHelpers
+
+ let(:note) { create(:note, noteable: noteable, project: project) }
+ let(:noteable) { create(:design, issue: issue) }
+
+ before do
+ enable_design_management
+ end
+
+ it 'can read, award emoji and reposition the note' do
+ expect(policy).to be_allowed(:reposition_note, :read_note, :award_emoji)
+ expect(policy).to be_disallowed(:admin_note, :resolve_note)
+ end
+
+ context 'when project is private' do
+ let(:project) { create(:project, :private) }
+
+ it_behaves_like 'user cannot read or act on the note'
+ end
+ end
+
context 'when the noteable is a personal snippet' do
let(:noteable) { create(:personal_snippet, :public) }
- let(:note) { create(:note, noteable: noteable, author: user) }
+ let(:note) { create(:note, noteable: noteable) }
- it 'can edit note' do
- expect(policy).to be_allowed(:admin_note)
- expect(policy).to be_allowed(:resolve_note)
- expect(policy).to be_allowed(:read_note)
- end
+ it_behaves_like 'a note on a public noteable'
+
+ context 'when user is the author of the personal snippet' do
+ let(:note) { create(:note, noteable: noteable, author: user) }
- context 'when it is private' do
- let(:noteable) { create(:personal_snippet, :private) }
+ it 'can edit note' do
+ expect(policy).to be_allowed(:read_note, :award_emoji, :admin_note, :reposition_note, :resolve_note)
+ end
+
+ context 'when it is private' do
+ let(:noteable) { create(:personal_snippet, :private) }
- it 'can not edit nor read the note' do
- expect(policy).to be_disallowed(:admin_note)
- expect(policy).to be_disallowed(:resolve_note)
- expect(policy).to be_disallowed(:read_note)
+ it_behaves_like 'user cannot read or act on the note'
end
end
end
context 'when the project is public' do
+ context 'when user is not the author of the note' do
+ let(:note) { create(:note, noteable: noteable, project: project) }
+
+ it_behaves_like 'a note on a public noteable'
+ end
+
context 'when the note author is not a project member' do
it 'can edit a note' do
expect(policy).to be_allowed(:admin_note)
+ expect(policy).to be_allowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
end
@@ -101,6 +140,7 @@ RSpec.describe NotePolicy do
it 'can edit note' do
expect(policy).to be_allowed(:admin_note)
+ expect(policy).to be_allowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
end
@@ -132,6 +172,7 @@ RSpec.describe NotePolicy do
it 'can edit a note' do
expect(policy).to be_allowed(:admin_note)
+ expect(policy).to be_allowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
end
@@ -140,6 +181,7 @@ RSpec.describe NotePolicy do
context 'when the note author is not a project member' do
it 'can not edit a note' do
expect(policy).to be_disallowed(:admin_note)
+ expect(policy).to be_disallowed(:reposition_note)
expect(policy).to be_disallowed(:resolve_note)
end
@@ -154,6 +196,7 @@ RSpec.describe NotePolicy do
it 'allows the author to manage the discussion' do
expect(policy).to be_allowed(:admin_note)
+ expect(policy).to be_allowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:read_note)
expect(policy).to be_allowed(:award_emoji)
@@ -180,21 +223,13 @@ RSpec.describe NotePolicy do
shared_examples_for 'user can act on the note' do
it 'allows the user to read the note' do
- expect(policy).not_to be_allowed(:admin_note)
+ expect(policy).to be_disallowed(:admin_note)
+ expect(policy).to be_disallowed(:reposition_note)
expect(policy).to be_allowed(:resolve_note)
expect(policy).to be_allowed(:award_emoji)
end
end
- shared_examples_for 'user cannot read or act on the note' do
- it 'allows user to read the note' do
- expect(policy).not_to be_allowed(:admin_note)
- expect(policy).not_to be_allowed(:resolve_note)
- expect(policy).not_to be_allowed(:read_note)
- expect(policy).not_to be_allowed(:award_emoji)
- end
- end
-
context 'when noteable is a public issue' do
let(:note) { create(:note, system: true, noteable: noteable, author: user, project: project) }
@@ -274,42 +309,42 @@ RSpec.describe NotePolicy do
shared_examples_for 'confidential notes permissions' do
it 'does not allow non members to read confidential notes and replies' do
- expect(permissions(non_member, confidential_note)).to be_disallowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ expect(permissions(non_member, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
end
it 'does not allow guests to read confidential notes and replies' do
- expect(permissions(guest, confidential_note)).to be_disallowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ expect(permissions(guest, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
end
it 'allows reporter to read all notes but not resolve and admin them' do
expect(permissions(reporter, confidential_note)).to be_allowed(:read_note, :award_emoji)
- expect(permissions(reporter, confidential_note)).to be_disallowed(:admin_note, :resolve_note)
+ expect(permissions(reporter, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note)
end
it 'allows developer to read and resolve all notes' do
expect(permissions(developer, confidential_note)).to be_allowed(:read_note, :award_emoji, :resolve_note)
- expect(permissions(developer, confidential_note)).to be_disallowed(:admin_note)
+ expect(permissions(developer, confidential_note)).to be_disallowed(:admin_note, :reposition_note)
end
it 'allows maintainers to read all notes and admin them' do
- expect(permissions(maintainer, confidential_note)).to be_allowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ expect(permissions(maintainer, confidential_note)).to be_allowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
end
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)
+ expect(permissions(admin, confidential_note)).to be_allowed(:read_note, :admin_note, :reposition_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)
+ expect(permissions(admin, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
end
end
it 'allows noteable author to read and resolve all notes' do
expect(permissions(author, confidential_note)).to be_allowed(:read_note, :resolve_note, :award_emoji)
- expect(permissions(author, confidential_note)).to be_disallowed(:admin_note)
+ expect(permissions(author, confidential_note)).to be_disallowed(:admin_note, :reposition_note)
end
end
@@ -321,7 +356,7 @@ RSpec.describe NotePolicy do
it 'allows noteable assignees to read all notes' do
expect(permissions(assignee, confidential_note)).to be_allowed(:read_note, :award_emoji)
- expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :resolve_note)
+ expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note)
end
end
@@ -333,7 +368,7 @@ RSpec.describe NotePolicy do
it 'allows noteable assignees to read all notes' do
expect(permissions(assignee, confidential_note)).to be_allowed(:read_note, :award_emoji)
- expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :resolve_note)
+ expect(permissions(assignee, confidential_note)).to be_disallowed(:admin_note, :reposition_note, :resolve_note)
end
end
@@ -350,11 +385,11 @@ RSpec.describe NotePolicy do
it 'allows snippet author to read and resolve all notes' do
expect(permissions(author, confidential_note)).to be_allowed(:read_note, :resolve_note, :award_emoji)
- expect(permissions(author, confidential_note)).to be_disallowed(:admin_note)
+ expect(permissions(author, confidential_note)).to be_disallowed(:admin_note, :reposition_note)
end
it 'does not allow maintainers to read confidential notes and replies' do
- expect(permissions(maintainer, confidential_note)).to be_disallowed(:read_note, :admin_note, :resolve_note, :award_emoji)
+ expect(permissions(maintainer, confidential_note)).to be_disallowed(:read_note, :admin_note, :reposition_note, :resolve_note, :award_emoji)
end
end
end
diff --git a/spec/policies/project_policy_spec.rb b/spec/policies/project_policy_spec.rb
index b28fb9a0255..6c281030618 100644
--- a/spec/policies/project_policy_spec.rb
+++ b/spec/policies/project_policy_spec.rb
@@ -697,6 +697,7 @@ RSpec.describe ProjectPolicy 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_package) }
it { is_expected.to be_allowed(:read_project) }
it { is_expected.to be_disallowed(:destroy_package) }
end
diff --git a/spec/policies/service_policy_spec.rb b/spec/policies/service_policy_spec.rb
new file mode 100644
index 00000000000..5d2c9c1f6c3
--- /dev/null
+++ b/spec/policies/service_policy_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ServicePolicy, :models do
+ let_it_be(:user) { create(:user) }
+ let(:project) { integration.project }
+
+ subject(:policy) { Ability.policy_for(user, integration) }
+
+ context 'when the integration is a prometheus_service' do
+ let(:integration) { create(:prometheus_service) }
+
+ describe 'rules' do
+ it { is_expected.to be_disallowed :admin_project }
+
+ context 'when maintainer' do
+ before do
+ project.add_maintainer(user)
+ end
+
+ it { is_expected.to be_allowed :admin_project }
+ end
+ end
+ end
+end
diff --git a/spec/policies/terraform/state_version_policy_spec.rb b/spec/policies/terraform/state_version_policy_spec.rb
new file mode 100644
index 00000000000..6614e073332
--- /dev/null
+++ b/spec/policies/terraform/state_version_policy_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Terraform::StateVersionPolicy do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:terraform_state) { create(:terraform_state, :with_version, project: project)}
+
+ subject { described_class.new(user, terraform_state.latest_version) }
+
+ describe 'rules' do
+ context 'no access' do
+ let(:user) { create(:user) }
+
+ it { is_expected.to be_disallowed(:read_terraform_state) }
+ it { is_expected.to be_disallowed(:admin_terraform_state) }
+ end
+
+ context 'developer' do
+ let(:user) { create(:user, developer_projects: [project]) }
+
+ it { is_expected.to be_allowed(:read_terraform_state) }
+ it { is_expected.to be_disallowed(:admin_terraform_state) }
+ end
+
+ context 'maintainer' do
+ let(:user) { create(:user, maintainer_projects: [project]) }
+
+ it { is_expected.to be_allowed(:read_terraform_state) }
+ it { is_expected.to be_allowed(:admin_terraform_state) }
+ end
+ end
+end
diff --git a/spec/policies/user_policy_spec.rb b/spec/policies/user_policy_spec.rb
index 38641558b6b..17ac7d0e44d 100644
--- a/spec/policies/user_policy_spec.rb
+++ b/spec/policies/user_policy_spec.rb
@@ -40,6 +40,46 @@ RSpec.describe UserPolicy do
end
end
+ describe "creating a different user's Personal Access Tokens" do
+ context 'when current_user is admin' do
+ let(:current_user) { create(:user, :admin) }
+
+ context 'when admin mode is enabled and current_user is not blocked', :enable_admin_mode do
+ it { is_expected.to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when admin mode is enabled and current_user is blocked', :enable_admin_mode do
+ let(:current_user) { create(:admin, :blocked) }
+
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when admin mode is disabled' do
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+
+ context 'when current_user is not an admin' do
+ context 'creating their own personal access tokens' do
+ subject { described_class.new(current_user, current_user) }
+
+ context 'when current_user is not blocked' do
+ it { is_expected.to be_allowed(:create_user_personal_access_token) }
+ end
+
+ context 'when current_user is blocked' do
+ let(:current_user) { create(:user, :blocked) }
+
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+
+ context "creating a different user's personal access tokens" do
+ it { is_expected.not_to be_allowed(:create_user_personal_access_token) }
+ end
+ end
+ end
+
shared_examples 'changing a user' do |ability|
context "when a regular user tries to destroy another regular user" do
it { is_expected.not_to be_allowed(ability) }
@@ -102,4 +142,22 @@ RSpec.describe UserPolicy do
end
end
end
+
+ describe "reading a user's group count" do
+ context "when current_user is an admin", :enable_admin_mode do
+ let(:current_user) { create(:user, :admin) }
+
+ it { is_expected.to be_allowed(:read_group_count) }
+ end
+
+ context "for self users" do
+ let(:user) { current_user }
+
+ it { is_expected.to be_allowed(:read_group_count) }
+ end
+
+ context "when accessing a different user's group count" do
+ it { is_expected.not_to be_allowed(:read_group_count) }
+ end
+ end
end
diff --git a/spec/policies/wiki_page_policy_spec.rb b/spec/policies/wiki_page_policy_spec.rb
index 093db9f8374..a2fa7f29135 100644
--- a/spec/policies/wiki_page_policy_spec.rb
+++ b/spec/policies/wiki_page_policy_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe WikiPagePolicy, :enable_admin_mode do
+RSpec.describe WikiPagePolicy do
include_context 'ProjectPolicyTable context'
include ProjectHelpers
using RSpec::Parameterized::TableSyntax
@@ -13,12 +13,13 @@ RSpec.describe WikiPagePolicy, :enable_admin_mode do
subject(:policy) { described_class.new(user, wiki_page) }
- where(:project_level, :feature_access_level, :membership, :expected_count) do
+ where(:project_level, :feature_access_level, :membership, :admin_mode, :expected_count) do
permission_table_for_guest_feature_access
end
with_them do
it "grants permission" do
+ enable_admin_mode!(user) if admin_mode
update_feature_access_level(project, feature_access_level)
if expected_count == 1
diff --git a/spec/presenters/issue_presenter_spec.rb b/spec/presenters/issue_presenter_spec.rb
index f08cd0f2026..55a6b50ffa7 100644
--- a/spec/presenters/issue_presenter_spec.rb
+++ b/spec/presenters/issue_presenter_spec.rb
@@ -40,4 +40,20 @@ RSpec.describe IssuePresenter do
expect(presenter.issue_path).to eq("/#{group.name}/#{project.name}/-/issues/#{issue.iid}")
end
end
+
+ describe '#project_emails_disabled?' do
+ subject { presenter.project_emails_disabled? }
+
+ it 'returns false when emails notifications is enabled for project' do
+ is_expected.to be(false)
+ end
+
+ context 'when emails notifications is disabled for project' do
+ before do
+ allow(project).to receive(:emails_disabled?).and_return(true)
+ end
+
+ it { is_expected.to be(true) }
+ end
+ end
end
diff --git a/spec/presenters/packages/detail/package_presenter_spec.rb b/spec/presenters/packages/detail/package_presenter_spec.rb
index 8ece27e9b5f..e38bbbe600c 100644
--- a/spec/presenters/packages/detail/package_presenter_spec.rb
+++ b/spec/presenters/packages/detail/package_presenter_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe ::Packages::Detail::PackagePresenter do
end
let(:pipeline_info) do
- pipeline = package.build_info.pipeline
+ pipeline = package.original_build_info.pipeline
{
created_at: pipeline.created_at,
id: pipeline.id,
@@ -56,16 +56,44 @@ RSpec.describe ::Packages::Detail::PackagePresenter do
}
end
- context 'detail_view' do
+ describe '#detail_view' do
context 'with build_info' do
let_it_be(:package) { create(:npm_package, :with_build, project: project) }
- let(:expected_package_details) { super().merge(pipeline: pipeline_info) }
+
+ let(:expected_package_details) do
+ super().merge(
+ pipeline: pipeline_info,
+ pipelines: [pipeline_info]
+ )
+ end
it 'returns details with pipeline' do
expect(presenter.detail_view).to match expected_package_details
end
end
+ context 'with multiple build_infos' do
+ let_it_be(:package) { create(:npm_package, :with_build, project: project) }
+ let_it_be(:build_info2) { create(:package_build_info, :with_pipeline, package: package) }
+
+ it 'returns details with two pipelines' do
+ expect(presenter.detail_view[:pipelines].size).to eq(2)
+ end
+ end
+
+ context 'with package_file_build_infos' do
+ let_it_be(:package) { create(:npm_package, :with_build, project: project) }
+
+ let_it_be(:package_file_build_info) do
+ create(:package_file_build_info, package_file: package.package_files.first,
+ pipeline: package.pipelines.first)
+ end
+
+ it 'returns details with package_file pipeline' do
+ expect(presenter.detail_view[:package_files].first[:pipelines].size).to eq(1)
+ end
+ end
+
context 'without build info' do
let_it_be(:package) { create(:npm_package, project: project) }
diff --git a/spec/presenters/release_presenter_spec.rb b/spec/presenters/release_presenter_spec.rb
index eb4d755205b..b518584569b 100644
--- a/spec/presenters/release_presenter_spec.rb
+++ b/spec/presenters/release_presenter_spec.rb
@@ -12,6 +12,11 @@ RSpec.describe ReleasePresenter do
let(:release) { create(:release, project: project) }
let(:presenter) { described_class.new(release, current_user: user) }
+ let(:base_url_params) { { scope: 'all', release_tag: release.tag } }
+ let(:opened_url_params) { { state: 'opened', **base_url_params } }
+ let(:merged_url_params) { { state: 'merged', **base_url_params } }
+ let(:closed_url_params) { { state: 'closed', **base_url_params } }
+
before do
project.add_developer(developer)
project.add_guest(guest)
@@ -55,15 +60,63 @@ RSpec.describe ReleasePresenter do
subject { presenter.self_url }
it 'returns its own url' do
- is_expected.to match /#{project_release_url(project, release)}/
+ is_expected.to eq(project_release_url(project, release))
+ end
+ end
+
+ describe '#opened_merge_requests_url' do
+ subject { presenter.opened_merge_requests_url }
+
+ it 'returns merge requests url with state=open' do
+ is_expected.to eq(project_merge_requests_url(project, opened_url_params))
+ end
+
+ context 'when release_mr_issue_urls feature flag is disabled' do
+ before do
+ stub_feature_flags(release_mr_issue_urls: false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#merged_merge_requests_url' do
+ subject { presenter.merged_merge_requests_url }
+
+ it 'returns merge requests url with state=merged' do
+ is_expected.to eq(project_merge_requests_url(project, merged_url_params))
+ end
+
+ context 'when release_mr_issue_urls feature flag is disabled' do
+ before do
+ stub_feature_flags(release_mr_issue_urls: false)
+ end
+
+ it { is_expected.to be_nil }
+ end
+ end
+
+ describe '#closed_merge_requests_url' do
+ subject { presenter.closed_merge_requests_url }
+
+ it 'returns merge requests url with state=closed' do
+ is_expected.to eq(project_merge_requests_url(project, closed_url_params))
+ end
+
+ context 'when release_mr_issue_urls feature flag is disabled' do
+ before do
+ stub_feature_flags(release_mr_issue_urls: false)
+ end
+
+ it { is_expected.to be_nil }
end
end
- describe '#merge_requests_url' do
- subject { presenter.merge_requests_url }
+ describe '#opened_issues_url' do
+ subject { presenter.opened_issues_url }
- it 'returns merge requests url' do
- is_expected.to match /#{project_merge_requests_url(project)}/
+ it 'returns issues url with state=open' do
+ is_expected.to eq(project_issues_url(project, opened_url_params))
end
context 'when release_mr_issue_urls feature flag is disabled' do
@@ -75,11 +128,11 @@ RSpec.describe ReleasePresenter do
end
end
- describe '#issues_url' do
- subject { presenter.issues_url }
+ describe '#closed_issues_url' do
+ subject { presenter.closed_issues_url }
- it 'returns merge requests url' do
- is_expected.to match /#{project_issues_url(project)}/
+ it 'returns issues url with state=closed' do
+ is_expected.to eq(project_issues_url(project, closed_url_params))
end
context 'when release_mr_issue_urls feature flag is disabled' do
@@ -95,7 +148,7 @@ RSpec.describe ReleasePresenter do
subject { presenter.edit_url }
it 'returns release edit url' do
- is_expected.to match /#{edit_project_release_url(project, release)}/
+ is_expected.to eq(edit_project_release_url(project, release))
end
context 'when a user is not allowed to update a release' do
diff --git a/spec/requests/api/admin/instance_clusters_spec.rb b/spec/requests/api/admin/instance_clusters_spec.rb
index 9d0661089a9..1052080aad4 100644
--- a/spec/requests/api/admin/instance_clusters_spec.rb
+++ b/spec/requests/api/admin/instance_clusters_spec.rb
@@ -13,6 +13,7 @@ RSpec.describe ::API::Admin::InstanceClusters do
user: admin_user,
projects: [project])
end
+
let(:project_cluster_id) { project_cluster.id }
describe "GET /admin/clusters" do
diff --git a/spec/requests/api/api_spec.rb b/spec/requests/api/api_spec.rb
index 7d637757f38..3cc8764de4a 100644
--- a/spec/requests/api/api_spec.rb
+++ b/spec/requests/api/api_spec.rb
@@ -92,4 +92,36 @@ RSpec.describe API::API do
end
end
end
+
+ context 'application context' do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { project.owner }
+
+ it 'logs all application context fields' do
+ allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do
+ Labkit::Context.current.to_h.tap do |log_context|
+ expect(log_context).to match('correlation_id' => an_instance_of(String),
+ 'meta.caller_id' => '/api/:version/projects/:id/issues',
+ 'meta.project' => project.full_path,
+ 'meta.root_namespace' => project.namespace.full_path,
+ 'meta.user' => user.username,
+ 'meta.feature_category' => 'issue_tracking')
+ end
+ end
+
+ get(api("/projects/#{project.id}/issues", user))
+ end
+
+ it 'skips fields that do not apply' do
+ allow_any_instance_of(Gitlab::GrapeLogging::Loggers::ContextLogger).to receive(:parameters) do
+ Labkit::Context.current.to_h.tap do |log_context|
+ expect(log_context).to match('correlation_id' => an_instance_of(String),
+ 'meta.caller_id' => '/api/:version/users',
+ 'meta.feature_category' => 'users')
+ end
+ end
+
+ get(api('/users'))
+ end
+ end
end
diff --git a/spec/requests/api/boards_spec.rb b/spec/requests/api/boards_spec.rb
index a63198c5407..36fc6101b84 100644
--- a/spec/requests/api/boards_spec.rb
+++ b/spec/requests/api/boards_spec.rb
@@ -35,7 +35,46 @@ RSpec.describe API::Boards do
it_behaves_like 'group and project boards', "/projects/:id/boards"
- describe "POST /projects/:id/boards/lists" do
+ describe "POST /projects/:id/boards" do
+ let(:url) { "/projects/#{board_parent.id}/boards" }
+
+ it 'creates a new issue board' do
+ post api(url, user), params: { name: 'foo' }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['name']).to eq('foo')
+ end
+
+ it 'fails to create a new board' do
+ post api(url, user), params: { some_name: 'foo' }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('name is missing')
+ end
+ end
+
+ describe "PUT /projects/:id/boards/:board_id" do
+ let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}" }
+
+ it 'updates the issue board' do
+ put api(url, user), params: { name: 'changed board name' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['name']).to eq('changed board name')
+ end
+ end
+
+ describe "DELETE /projects/:id/boards/:board_id" do
+ let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}" }
+
+ it 'delete the issue board' do
+ delete api(url, user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ describe "POST /projects/:id/boards/:board_id/lists" do
let(:url) { "/projects/#{board_parent.id}/boards/#{board.id}/lists" }
it 'creates a new issue board list for group labels' do
@@ -65,7 +104,7 @@ RSpec.describe API::Boards do
end
end
- describe "POST /groups/:id/boards/lists" do
+ describe "POST /groups/:id/boards/:board_id/lists" do
let_it_be(:group) { create(:group) }
let_it_be(:board_parent) { create(:group, parent: group ) }
let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
diff --git a/spec/requests/api/ci/pipelines_spec.rb b/spec/requests/api/ci/pipelines_spec.rb
index 577b43e6e42..767b5704851 100644
--- a/spec/requests/api/ci/pipelines_spec.rb
+++ b/spec/requests/api/ci/pipelines_spec.rb
@@ -325,236 +325,113 @@ RSpec.describe API::Ci::Pipelines do
end
end
- context 'with ci_jobs_finder_refactor ff enabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: true)
+ context 'authorized user' do
+ it 'returns pipeline jobs' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
end
- context 'authorized user' do
- it 'returns pipeline jobs' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- end
-
- it 'returns correct values' do
- expect(json_response).not_to be_empty
- expect(json_response.first['commit']['id']).to eq project.commit.id
- expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
- expect(json_response.first['artifacts_file']).to be_nil
- expect(json_response.first['artifacts']).to be_an Array
- expect(json_response.first['artifacts']).to be_empty
- end
-
- it_behaves_like 'a job with artifacts and trace' do
- let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
- end
-
- it 'returns pipeline data' do
- json_job = json_response.first
-
- expect(json_job['pipeline']).not_to be_empty
- expect(json_job['pipeline']['id']).to eq job.pipeline.id
- expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
- expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
- expect(json_job['pipeline']['status']).to eq job.pipeline.status
- end
-
- context 'filter jobs with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
-
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
- end
-
- context 'filter jobs with hash' do
- let(:query) { { scope: { hello: 'pending', world: 'running' } } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'filter jobs with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
-
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
- end
-
- context 'respond 400 when scope contains invalid state' do
- let(:query) { { scope: %w(unknown running) } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'jobs in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:job2) { create(:ci_build, pipeline: pipeline2) }
-
- it 'excludes jobs from other pipelines' do
- json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
- end
- end
-
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.count
-
- create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
-
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
- end
+ it 'returns correct values' do
+ expect(json_response).not_to be_empty
+ expect(json_response.first['commit']['id']).to eq project.commit.id
+ expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
+ expect(json_response.first['artifacts_file']).to be_nil
+ expect(json_response.first['artifacts']).to be_an Array
+ expect(json_response.first['artifacts']).to be_empty
end
- context 'no pipeline is found' do
- it 'does not return jobs' do
- get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
-
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it_behaves_like 'a job with artifacts and trace' do
+ let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
end
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
+ it 'returns pipeline data' do
+ json_job = json_response.first
- it 'does not return jobs' do
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'when user is guest' do
- let(:guest) { create(:project_member, :guest, project: project).user }
- let(:api_user) { guest }
-
- it 'does not return jobs' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ expect(json_job['pipeline']).not_to be_empty
+ expect(json_job['pipeline']['id']).to eq job.pipeline.id
+ expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
+ expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
+ expect(json_job['pipeline']['status']).to eq job.pipeline.status
end
- end
- context 'with ci_jobs_finder ff disabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: false)
- end
+ context 'filter jobs with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
- context 'authorized user' do
- it 'returns pipeline jobs' do
+ it do
expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
expect(json_response).to be_an Array
end
+ end
- it 'returns correct values' do
- expect(json_response).not_to be_empty
- expect(json_response.first['commit']['id']).to eq project.commit.id
- expect(Time.parse(json_response.first['artifacts_expire_at'])).to be_like_time(job.artifacts_expire_at)
- expect(json_response.first['artifacts_file']).to be_nil
- expect(json_response.first['artifacts']).to be_an Array
- expect(json_response.first['artifacts']).to be_empty
- end
-
- it_behaves_like 'a job with artifacts and trace' do
- let(:api_endpoint) { "/projects/#{project.id}/pipelines/#{pipeline.id}/jobs" }
- end
-
- it 'returns pipeline data' do
- json_job = json_response.first
+ context 'filter jobs with hash' do
+ let(:query) { { scope: { hello: 'pending', world: 'running' } } }
- expect(json_job['pipeline']).not_to be_empty
- expect(json_job['pipeline']['id']).to eq job.pipeline.id
- expect(json_job['pipeline']['ref']).to eq job.pipeline.ref
- expect(json_job['pipeline']['sha']).to eq job.pipeline.sha
- expect(json_job['pipeline']['status']).to eq job.pipeline.status
- end
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
- context 'filter jobs with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
+ context 'filter jobs with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
+ it do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
end
+ end
- context 'filter jobs with hash' do
- let(:query) { { scope: { hello: 'pending', world: 'running' } } }
+ context 'respond 400 when scope contains invalid state' do
+ let(:query) { { scope: %w(unknown running) } }
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
+ end
- context 'filter jobs with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
+ context 'jobs in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:job2) { create(:ci_build, pipeline: pipeline2) }
- it do
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- end
+ it 'excludes jobs from other pipelines' do
+ json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
end
+ end
- context 'respond 400 when scope contains invalid state' do
- let(:query) { { scope: %w(unknown running) } }
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.count
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
+ create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
- context 'jobs in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:job2) { create(:ci_build, pipeline: pipeline2) }
-
- it 'excludes jobs from other pipelines' do
- json_response.each { |job| expect(job['pipeline']['id']).to eq(pipeline.id) }
- end
- end
-
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.count
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
+ end
+ end
- create_list(:ci_build, 3, :trace_artifact, :artifacts, :test_reports, pipeline: pipeline)
+ context 'no pipeline is found' do
+ it 'does not return jobs' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/jobs", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
- end
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
- context 'no pipeline is found' do
- it 'does not return jobs' do
- get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/jobs", user)
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+ it 'does not return jobs' do
expect(json_response['message']).to eq '404 Project Not Found'
expect(response).to have_gitlab_http_status(:not_found)
end
end
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
-
- it 'does not return jobs' do
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
+ context 'when user is guest' do
+ let(:guest) { create(:project_member, :guest, project: project).user }
+ let(:api_user) { guest }
- context 'when user is guest' do
- let(:guest) { create(:project_member, :guest, project: project).user }
- let(:api_user) { guest }
-
- it 'does not return jobs' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ it 'does not return jobs' do
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
@@ -583,314 +460,152 @@ RSpec.describe API::Ci::Pipelines do
end
end
- context 'with ci_jobs_finder_refactor ff enabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: true)
+ context 'authorized user' do
+ it 'returns pipeline bridges' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
end
- context 'authorized user' do
- it 'returns pipeline bridges' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- end
+ it 'returns correct values' do
+ expect(json_response).not_to be_empty
+ expect(json_response.first['commit']['id']).to eq project.commit.id
+ expect(json_response.first['id']).to eq bridge.id
+ expect(json_response.first['name']).to eq bridge.name
+ expect(json_response.first['stage']).to eq bridge.stage
+ end
- it 'returns correct values' do
- expect(json_response).not_to be_empty
- expect(json_response.first['commit']['id']).to eq project.commit.id
- expect(json_response.first['id']).to eq bridge.id
- expect(json_response.first['name']).to eq bridge.name
- expect(json_response.first['stage']).to eq bridge.stage
- end
+ it 'returns pipeline data' do
+ json_bridge = json_response.first
- it 'returns pipeline data' do
- json_bridge = json_response.first
+ expect(json_bridge['pipeline']).not_to be_empty
+ expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
+ expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
+ expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
+ expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
+ end
- expect(json_bridge['pipeline']).not_to be_empty
- expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
- expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
- expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
- expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
- end
+ it 'returns downstream pipeline data' do
+ json_bridge = json_response.first
- it 'returns downstream pipeline data' do
- json_bridge = json_response.first
+ expect(json_bridge['downstream_pipeline']).not_to be_empty
+ expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
+ expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
+ expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
+ expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
+ end
- expect(json_bridge['downstream_pipeline']).not_to be_empty
- expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
- expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
- expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
- expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
+ context 'filter bridges' do
+ before_all do
+ create_bridge(pipeline, :pending)
+ create_bridge(pipeline, :running)
end
- context 'filter bridges' do
- before_all do
- create_bridge(pipeline, :pending)
- create_bridge(pipeline, :running)
- end
-
- context 'with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
-
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 1
- expect(json_response.first["status"]).to eq "pending"
- end
- end
-
- context 'with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
+ context 'with one scope element' do
+ let(:query) { { 'scope' => 'pending' } }
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 2
- json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
- end
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 1
+ expect(json_response.first["status"]).to eq "pending"
end
end
- context 'respond 400 when scope contains invalid state' do
- context 'in an array' do
- let(:query) { { scope: %w(unknown running) } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'in a hash' do
- let(:query) { { scope: { unknown: true } } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
+ context 'with array of scope elements' do
+ let(:query) { { scope: %w(pending running) } }
- context 'in a string' do
- let(:query) { { scope: "unknown" } }
+ it :skip_before_request do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- it { expect(response).to have_gitlab_http_status(:bad_request) }
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq 2
+ json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
end
end
+ end
- context 'bridges in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
+ context 'respond 400 when scope contains invalid state' do
+ context 'in an array' do
+ let(:query) { { scope: %w(unknown running) } }
- it 'excludes bridges from other pipelines' do
- json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
- end
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
end
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.count
-
- 3.times { create_bridge(pipeline) }
+ context 'in a hash' do
+ let(:query) { { scope: { unknown: true } } }
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
end
- end
- context 'no pipeline is found' do
- it 'does not return bridges' do
- get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
+ context 'in a string' do
+ let(:query) { { scope: "unknown" } }
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
+ it { expect(response).to have_gitlab_http_status(:bad_request) }
end
end
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
+ context 'bridges in different pipelines' do
+ let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
+ let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
- it 'does not return bridges' do
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it 'excludes bridges from other pipelines' do
+ json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
end
+ end
- context 'when user is guest' do
- let(:api_user) { guest }
- let(:guest) { create(:project_member, :guest, project: project).user }
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.count
- it 'does not return bridges' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ 3.times { create_bridge(pipeline) }
- context 'when user has no read_build access for project' do
- before do
- project.add_guest(api_user)
- end
-
- it 'does not return bridges' do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
+ expect do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
+ end.not_to exceed_all_query_limit(control_count)
end
end
- context 'with ci_jobs_finder_refactor ff disabled' do
- before do
- stub_feature_flags(ci_jobs_finder_refactor: false)
- end
-
- context 'authorized user' do
- it 'returns pipeline bridges' do
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- end
-
- it 'returns correct values' do
- expect(json_response).not_to be_empty
- expect(json_response.first['commit']['id']).to eq project.commit.id
- expect(json_response.first['id']).to eq bridge.id
- expect(json_response.first['name']).to eq bridge.name
- expect(json_response.first['stage']).to eq bridge.stage
- end
-
- it 'returns pipeline data' do
- json_bridge = json_response.first
+ context 'no pipeline is found' do
+ it 'does not return bridges' do
+ get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
- expect(json_bridge['pipeline']).not_to be_empty
- expect(json_bridge['pipeline']['id']).to eq bridge.pipeline.id
- expect(json_bridge['pipeline']['ref']).to eq bridge.pipeline.ref
- expect(json_bridge['pipeline']['sha']).to eq bridge.pipeline.sha
- expect(json_bridge['pipeline']['status']).to eq bridge.pipeline.status
- end
-
- it 'returns downstream pipeline data' do
- json_bridge = json_response.first
-
- expect(json_bridge['downstream_pipeline']).not_to be_empty
- expect(json_bridge['downstream_pipeline']['id']).to eq downstream_pipeline.id
- expect(json_bridge['downstream_pipeline']['ref']).to eq downstream_pipeline.ref
- expect(json_bridge['downstream_pipeline']['sha']).to eq downstream_pipeline.sha
- expect(json_bridge['downstream_pipeline']['status']).to eq downstream_pipeline.status
- end
-
- context 'filter bridges' do
- before_all do
- create_bridge(pipeline, :pending)
- create_bridge(pipeline, :running)
- end
-
- context 'with one scope element' do
- let(:query) { { 'scope' => 'pending' } }
-
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 1
- expect(json_response.first["status"]).to eq "pending"
- end
- end
-
- context 'with array of scope elements' do
- let(:query) { { scope: %w(pending running) } }
-
- it :skip_before_request do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response).to be_an Array
- expect(json_response.count).to eq 2
- json_response.each { |r| expect(%w(pending running).include?(r['status'])).to be true }
- end
- end
- end
-
- context 'respond 400 when scope contains invalid state' do
- context 'in an array' do
- let(:query) { { scope: %w(unknown running) } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'in a hash' do
- let(:query) { { scope: { unknown: true } } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
-
- context 'in a string' do
- let(:query) { { scope: "unknown" } }
-
- it { expect(response).to have_gitlab_http_status(:bad_request) }
- end
- end
-
- context 'bridges in different pipelines' do
- let!(:pipeline2) { create(:ci_empty_pipeline, project: project) }
- let!(:bridge2) { create(:ci_bridge, pipeline: pipeline2) }
-
- it 'excludes bridges from other pipelines' do
- json_response.each { |bridge| expect(bridge['pipeline']['id']).to eq(pipeline.id) }
- end
- end
-
- it 'avoids N+1 queries' do
- control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.count
-
- 3.times { create_bridge(pipeline) }
-
- expect do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user), params: query
- end.not_to exceed_all_query_limit(control_count)
- end
+ expect(json_response['message']).to eq '404 Project Not Found'
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
- context 'no pipeline is found' do
- it 'does not return bridges' do
- get api("/projects/#{project2.id}/pipelines/#{pipeline.id}/bridges", user)
+ context 'unauthorized user' do
+ context 'when user is not logged in' do
+ let(:api_user) { nil }
+ it 'does not return bridges' do
expect(json_response['message']).to eq '404 Project Not Found'
expect(response).to have_gitlab_http_status(:not_found)
end
end
- context 'unauthorized user' do
- context 'when user is not logged in' do
- let(:api_user) { nil }
+ context 'when user is guest' do
+ let(:api_user) { guest }
+ let(:guest) { create(:project_member, :guest, project: project).user }
- it 'does not return bridges' do
- expect(json_response['message']).to eq '404 Project Not Found'
- expect(response).to have_gitlab_http_status(:not_found)
- end
+ it 'does not return bridges' do
+ expect(response).to have_gitlab_http_status(:forbidden)
end
+ end
- context 'when user is guest' do
- let(:api_user) { guest }
- let(:guest) { create(:project_member, :guest, project: project).user }
-
- it 'does not return bridges' do
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ context 'when user has no read_build access for project' do
+ before do
+ project.add_guest(api_user)
end
- context 'when user has no read_build access for project' do
- before do
- project.add_guest(api_user)
- end
-
- it 'does not return bridges' do
- get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
- expect(response).to have_gitlab_http_status(:forbidden)
- end
+ it 'does not return bridges' do
+ get api("/projects/#{project.id}/pipelines/#{pipeline.id}/bridges", api_user)
+ expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
diff --git a/spec/requests/api/commits_spec.rb b/spec/requests/api/commits_spec.rb
index d455ed9c194..de2cfb8fea0 100644
--- a/spec/requests/api/commits_spec.rb
+++ b/spec/requests/api/commits_spec.rb
@@ -1991,6 +1991,17 @@ RSpec.describe API::Commits do
expect(json_response['x509_certificate']['x509_issuer']['subject']).to eq(commit.signature.x509_certificate.x509_issuer.subject)
expect(json_response['x509_certificate']['x509_issuer']['subject_key_identifier']).to eq(commit.signature.x509_certificate.x509_issuer.subject_key_identifier)
expect(json_response['x509_certificate']['x509_issuer']['crl_url']).to eq(commit.signature.x509_certificate.x509_issuer.crl_url)
+ expect(json_response['commit_source']).to eq('gitaly')
+ end
+
+ context 'with Rugged enabled', :enable_rugged do
+ it 'returns correct JSON' do
+ get api(route, current_user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['signature_type']).to eq('PGP')
+ expect(json_response['commit_source']).to eq('rugged')
+ end
end
end
end
diff --git a/spec/requests/api/container_repositories_spec.rb b/spec/requests/api/container_repositories_spec.rb
new file mode 100644
index 00000000000..8d7494ffce1
--- /dev/null
+++ b/spec/requests/api/container_repositories_spec.rb
@@ -0,0 +1,88 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::ContainerRepositories do
+ let_it_be(:project) { create(:project, :private) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:repository) { create(:container_repository, project: project) }
+
+ let(:users) do
+ {
+ anonymous: nil,
+ guest: guest,
+ reporter: reporter
+ }
+ end
+
+ let(:api_user) { reporter }
+
+ before do
+ project.add_reporter(reporter)
+ project.add_guest(guest)
+
+ stub_container_registry_config(enabled: true)
+ end
+
+ describe 'GET /registry/repositories/:id' do
+ let(:url) { "/registry/repositories/#{repository.id}" }
+
+ subject { get api(url, api_user) }
+
+ it_behaves_like 'rejected container repository access', :guest, :forbidden
+ it_behaves_like 'rejected container repository access', :anonymous, :unauthorized
+
+ context 'for allowed user' do
+ it 'returns a repository' do
+ subject
+
+ expect(json_response['id']).to eq(repository.id)
+ expect(response.body).not_to include('tags')
+ end
+
+ it 'returns a matching schema' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('registry/repository')
+ end
+
+ context 'with tags param' do
+ let(:url) { "/registry/repositories/#{repository.id}?tags=true" }
+
+ before do
+ stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true)
+ end
+
+ it 'returns a repository and its tags' do
+ subject
+
+ expect(json_response['id']).to eq(repository.id)
+ expect(response.body).to include('tags')
+ end
+ end
+
+ context 'with tags_count param' do
+ let(:url) { "/registry/repositories/#{repository.id}?tags_count=true" }
+
+ before do
+ stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true)
+ end
+
+ it 'returns a repository and its tags_count' do
+ subject
+
+ expect(response.body).to include('tags_count')
+ expect(json_response['tags_count']).to eq(2)
+ end
+ end
+ end
+
+ context 'with invalid repository id' do
+ let(:url) { "/registry/repositories/#{non_existing_record_id}" }
+
+ it_behaves_like 'returning response status', :not_found
+ end
+ end
+end
diff --git a/spec/requests/api/dependency_proxy_spec.rb b/spec/requests/api/dependency_proxy_spec.rb
new file mode 100644
index 00000000000..d59f2bf06e3
--- /dev/null
+++ b/spec/requests/api/dependency_proxy_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::DependencyProxy, api: true do
+ include ExclusiveLeaseHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:blob) { create(:dependency_proxy_blob )}
+ let_it_be(:group, reload: true) { blob.group }
+
+ before do
+ group.add_owner(user)
+ stub_config(dependency_proxy: { enabled: true })
+ stub_last_activity_update
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ describe 'DELETE /groups/:id/dependency_proxy/cache' do
+ subject { delete api("/groups/#{group.id}/dependency_proxy/cache", user) }
+
+ context 'with feature available and enabled' do
+ let_it_be(:lease_key) { "dependency_proxy:delete_group_blobs:#{group.id}" }
+
+ context 'an admin user' do
+ it 'deletes the blobs and returns no content' do
+ stub_exclusive_lease(lease_key, timeout: 1.hour)
+ expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+
+ context 'called multiple times in one hour', :clean_gitlab_redis_shared_state do
+ it 'returns 409 with an error message' do
+ stub_exclusive_lease_taken(lease_key, timeout: 1.hour)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:conflict)
+ expect(response.body).to include('This request has already been made.')
+ end
+
+ it 'executes service only for the first time' do
+ expect(PurgeDependencyProxyCacheWorker).to receive(:perform_async).once
+
+ 2.times { subject }
+ end
+ end
+ end
+
+ context 'a non-admin' do
+ let(:user) { create(:user) }
+
+ before do
+ group.add_maintainer(user)
+ end
+
+ it_behaves_like 'returning response status', :forbidden
+ end
+ end
+
+ context 'depencency proxy is not enabled' do
+ before do
+ stub_config(dependency_proxy: { enabled: false })
+ end
+
+ it_behaves_like 'returning response status', :not_found
+ end
+ end
+end
diff --git a/spec/requests/api/feature_flags_user_lists_spec.rb b/spec/requests/api/feature_flags_user_lists_spec.rb
index 469210040dd..e2a3f92df10 100644
--- a/spec/requests/api/feature_flags_user_lists_spec.rb
+++ b/spec/requests/api/feature_flags_user_lists_spec.rb
@@ -95,6 +95,39 @@ RSpec.describe API::FeatureFlagsUserLists do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq([])
end
+
+ context 'when filtering' do
+ it 'returns lists matching the search term' do
+ create_list(name: 'test_list', user_xids: 'user1')
+ create_list(name: 'list_b', user_xids: 'user1,user2,user3')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists?search=test", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }).to eq(['test_list'])
+ end
+
+ it 'returns lists matching multiple search terms' do
+ create_list(name: 'test_list', user_xids: 'user1')
+ create_list(name: 'list_b', user_xids: 'user1,user2,user3')
+ create_list(name: 'test_again', user_xids: 'user1,user2,user3')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists?search=test list", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }).to eq(['test_list'])
+ end
+
+ it 'returns all lists with no query' do
+ create_list(name: 'list_a', user_xids: 'user1')
+ create_list(name: 'list_b', user_xids: 'user1,user2,user3')
+
+ get api("/projects/#{project.id}/feature_flags_user_lists?search=", developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.map { |list| list['name'] }.sort).to eq(%w[list_a list_b])
+ end
+ end
end
describe 'GET /projects/:id/feature_flags_user_lists/:iid' do
diff --git a/spec/requests/api/files_spec.rb b/spec/requests/api/files_spec.rb
index f77f127ddc8..8cd2f00a718 100644
--- a/spec/requests/api/files_spec.rb
+++ b/spec/requests/api/files_spec.rb
@@ -73,18 +73,20 @@ RSpec.describe API::Files do
describe "HEAD /projects/:id/repository/files/:file_path" do
shared_examples_for 'repository files' do
+ let(:options) { {} }
+
it 'returns 400 when file path is invalid' do
- head api(route(rouge_file_path), current_user), params: params
+ head api(route(rouge_file_path), current_user, **options), params: params
expect(response).to have_gitlab_http_status(:bad_request)
end
it_behaves_like 'when path is absolute' do
- subject { head api(route(absolute_path), current_user), params: params }
+ subject { head api(route(absolute_path), current_user, **options), params: params }
end
it 'returns file attributes in headers' do
- head api(route(file_path), current_user), params: params
+ head api(route(file_path), current_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Gitlab-File-Path']).to eq(CGI.unescape(file_path))
@@ -98,7 +100,7 @@ RSpec.describe API::Files do
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9"
- head api(route(file_path), current_user), params: params
+ head api(route(file_path), current_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers['X-Gitlab-File-Name']).to eq('commit.js.coffee')
@@ -107,7 +109,7 @@ RSpec.describe API::Files do
context 'when mandatory params are not given' do
it "responds with a 400 status" do
- head api(route("any%2Ffile"), current_user)
+ head api(route("any%2Ffile"), current_user, **options)
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -117,7 +119,7 @@ RSpec.describe API::Files do
it "responds with a 404 status" do
params[:ref] = 'master'
- head api(route('app%2Fmodels%2Fapplication%2Erb'), current_user), params: params
+ head api(route('app%2Fmodels%2Fapplication%2Erb'), current_user, **options), params: params
expect(response).to have_gitlab_http_status(:not_found)
end
@@ -127,7 +129,7 @@ RSpec.describe API::Files do
include_context 'disabled repository'
it "responds with a 403 status" do
- head api(route(file_path), current_user), params: params
+ head api(route(file_path), current_user, **options), params: params
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -154,8 +156,8 @@ RSpec.describe API::Files do
context 'when PATs are used' do
it_behaves_like 'repository files' do
let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) }
- let(:current_user) { user }
- let(:api_user) { { personal_access_token: token } }
+ let(:current_user) { nil }
+ let(:options) { { personal_access_token: token } }
end
end
@@ -174,21 +176,21 @@ RSpec.describe API::Files do
describe "GET /projects/:id/repository/files/:file_path" do
shared_examples_for 'repository files' do
- let(:api_user) { current_user }
+ let(:options) { {} }
it 'returns 400 for invalid file path' do
- get api(route(rouge_file_path), api_user), params: params
+ get api(route(rouge_file_path), api_user, **options), params: params
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq(invalid_file_message)
end
it_behaves_like 'when path is absolute' do
- subject { get api(route(absolute_path), api_user), params: params }
+ subject { get api(route(absolute_path), api_user, **options), params: params }
end
it 'returns file attributes as json' do
- get api(route(file_path), api_user), params: params
+ get api(route(file_path), api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['file_path']).to eq(CGI.unescape(file_path))
@@ -201,10 +203,10 @@ RSpec.describe API::Files do
it 'returns json when file has txt extension' do
file_path = "bar%2Fbranch-test.txt"
- get api(route(file_path), api_user), params: params
+ get api(route(file_path), api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
- expect(response.content_type).to eq('application/json')
+ expect(response.media_type).to eq('application/json')
end
context 'with filename with pathspec characters' do
@@ -218,7 +220,7 @@ RSpec.describe API::Files do
it 'returns JSON wth commit SHA' do
params[:ref] = 'master'
- get api(route(file_path), api_user), params: params
+ get api(route(file_path), api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['file_path']).to eq(file_path)
@@ -232,7 +234,7 @@ RSpec.describe API::Files do
file_path = "files%2Fjs%2Fcommit%2Ejs%2Ecoffee"
params[:ref] = "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9"
- get api(route(file_path), api_user), params: params
+ get api(route(file_path), api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['file_name']).to eq('commit.js.coffee')
@@ -244,7 +246,7 @@ RSpec.describe API::Files do
url = route(file_path) + "/raw"
expect(Gitlab::Workhorse).to receive(:send_git_blob)
- get api(url, api_user), params: params
+ get api(url, api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
expect(headers[Gitlab::Workhorse::DETECT_HEADER]).to eq "true"
@@ -253,7 +255,7 @@ RSpec.describe API::Files do
it 'returns blame file info' do
url = route(file_path) + '/blame'
- get api(url, api_user), params: params
+ get api(url, api_user, **options), params: params
expect(response).to have_gitlab_http_status(:ok)
end
@@ -261,14 +263,14 @@ RSpec.describe API::Files do
it 'sets inline content disposition by default' do
url = route(file_path) + "/raw"
- get api(url, api_user), params: params
+ get api(url, api_user, **options), params: params
expect(headers['Content-Disposition']).to eq(%q(inline; filename="popen.rb"; filename*=UTF-8''popen.rb))
end
context 'when mandatory params are not given' do
it_behaves_like '400 response' do
- let(:request) { get api(route("any%2Ffile"), current_user) }
+ let(:request) { get api(route("any%2Ffile"), current_user, **options) }
end
end
@@ -276,7 +278,7 @@ RSpec.describe API::Files do
let(:params) { { ref: 'master' } }
it_behaves_like '404 response' do
- let(:request) { get api(route('app%2Fmodels%2Fapplication%2Erb'), api_user), params: params }
+ let(:request) { get api(route('app%2Fmodels%2Fapplication%2Erb'), api_user, **options), params: params }
let(:message) { '404 File Not Found' }
end
end
@@ -285,7 +287,7 @@ RSpec.describe API::Files do
include_context 'disabled repository'
it_behaves_like '403 response' do
- let(:request) { get api(route(file_path), api_user), params: params }
+ let(:request) { get api(route(file_path), api_user, **options), params: params }
end
end
end
@@ -294,6 +296,7 @@ RSpec.describe API::Files do
it_behaves_like 'repository files' do
let(:project) { create(:project, :public, :repository) }
let(:current_user) { nil }
+ let(:api_user) { nil }
end
end
@@ -301,7 +304,8 @@ RSpec.describe API::Files do
it_behaves_like 'repository files' do
let(:token) { create(:personal_access_token, scopes: ['read_repository'], user: user) }
let(:current_user) { user }
- let(:api_user) { { personal_access_token: token } }
+ let(:api_user) { nil }
+ let(:options) { { personal_access_token: token } }
end
end
@@ -315,6 +319,7 @@ RSpec.describe API::Files do
context 'when authenticated', 'as a developer' do
it_behaves_like 'repository files' do
let(:current_user) { user }
+ let(:api_user) { user }
end
end
@@ -532,13 +537,16 @@ RSpec.describe API::Files do
expect(response).to have_gitlab_http_status(:ok)
end
- it_behaves_like 'uncached response' do
- before do
- url = route('.gitignore') + "/raw"
- expect(Gitlab::Workhorse).to receive(:send_git_blob)
+ it 'sets no-cache headers' do
+ url = route('.gitignore') + "/raw"
+ expect(Gitlab::Workhorse).to receive(:send_git_blob)
- get api(url, current_user), params: params
- end
+ get api(url, current_user), params: params
+
+ expect(response.headers["Cache-Control"]).to include("no-store")
+ expect(response.headers["Cache-Control"]).to include("no-cache")
+ expect(response.headers["Pragma"]).to eq("no-cache")
+ expect(response.headers["Expires"]).to eq("Fri, 01 Jan 1990 00:00:00 GMT")
end
context 'when mandatory params are not given' do
@@ -687,7 +695,7 @@ RSpec.describe API::Files do
post api(route("new_file_with_author%2Etxt"), user), params: params
expect(response).to have_gitlab_http_status(:created)
- expect(response.content_type).to eq('application/json')
+ expect(response.media_type).to eq('application/json')
last_commit = project.repository.commit.raw
expect(last_commit.author_email).to eq(author_email)
expect(last_commit.author_name).to eq(author_name)
diff --git a/spec/requests/api/generic_packages_spec.rb b/spec/requests/api/generic_packages_spec.rb
index 2cb686167f1..b8e79853486 100644
--- a/spec/requests/api/generic_packages_spec.rb
+++ b/spec/requests/api/generic_packages_spec.rb
@@ -195,7 +195,7 @@ RSpec.describe API::GenericPackages do
package = project.packages.generic.last
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
- expect(package.build_info).to be_nil
+ expect(package.original_build_info).to be_nil
package_file = package.package_files.last
expect(package_file.file_name).to eq('myfile.tar.gz')
@@ -215,7 +215,7 @@ RSpec.describe API::GenericPackages do
package = project.packages.generic.last
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
- expect(package.build_info.pipeline).to eq(ci_build.pipeline)
+ expect(package.original_build_info.pipeline).to eq(ci_build.pipeline)
package_file = package.package_files.last
expect(package_file.file_name).to eq('myfile.tar.gz')
diff --git a/spec/requests/api/graphql/ci/jobs_spec.rb b/spec/requests/api/graphql/ci/jobs_spec.rb
index 7d416f4720b..618705e5f94 100644
--- a/spec/requests/api/graphql/ci/jobs_spec.rb
+++ b/spec/requests/api/graphql/ci/jobs_spec.rb
@@ -34,6 +34,9 @@ RSpec.describe 'Query.project.pipeline.stages.groups.jobs' do
jobs {
nodes {
name
+ pipeline {
+ id
+ }
}
}
}
@@ -53,7 +56,7 @@ RSpec.describe 'Query.project.pipeline.stages.groups.jobs' do
end
context 'when fetching jobs from the pipeline' do
- it 'avoids N+1 queries' do
+ it 'avoids N+1 queries', :aggregate_failures do
control_count = ActiveRecord::QueryRecorder.new do
post_graphql(query, current_user: user)
end
@@ -86,8 +89,27 @@ RSpec.describe 'Query.project.pipeline.stages.groups.jobs' do
docker_jobs = docker_group.dig('jobs', 'nodes')
rspec_jobs = rspec_group.dig('jobs', 'nodes')
- expect(docker_jobs).to eq([{ 'name' => 'docker 1 2' }, { 'name' => 'docker 2 2' }])
- expect(rspec_jobs).to eq([{ 'name' => 'rspec 1 2' }, { 'name' => 'rspec 2 2' }])
+ expect(docker_jobs).to eq([
+ {
+ 'name' => 'docker 1 2',
+ 'pipeline' => { 'id' => pipeline.to_global_id.to_s }
+ },
+ {
+ 'name' => 'docker 2 2',
+ 'pipeline' => { 'id' => pipeline.to_global_id.to_s }
+ }
+ ])
+
+ expect(rspec_jobs).to eq([
+ {
+ 'name' => 'rspec 1 2',
+ 'pipeline' => { 'id' => pipeline.to_global_id.to_s }
+ },
+ {
+ 'name' => 'rspec 2 2',
+ 'pipeline' => { 'id' => pipeline.to_global_id.to_s }
+ }
+ ])
end
end
end
diff --git a/spec/requests/api/graphql/ci/pipelines_spec.rb b/spec/requests/api/graphql/ci/pipelines_spec.rb
new file mode 100644
index 00000000000..414ddabbac9
--- /dev/null
+++ b/spec/requests/api/graphql/ci/pipelines_spec.rb
@@ -0,0 +1,221 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Query.project(fullPath).pipelines' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:first_user) { create(:user) }
+ let_it_be(:second_user) { create(:user) }
+
+ describe '.jobs' do
+ let_it_be(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipelines {
+ nodes {
+ jobs {
+ nodes {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ it 'fetches the jobs without an N+1' do
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, pipeline: pipeline, name: 'Job 1')
+
+ control_count = ActiveRecord::QueryRecorder.new do
+ post_graphql(query, current_user: first_user)
+ end
+
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, pipeline: pipeline, name: 'Job 2')
+
+ expect do
+ post_graphql(query, current_user: second_user)
+ end.not_to exceed_query_limit(control_count)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ pipelines_data = graphql_data.dig('project', 'pipelines', 'nodes')
+
+ job_names = pipelines_data.map do |pipeline_data|
+ jobs_data = pipeline_data.dig('jobs', 'nodes')
+ jobs_data.map { |job_data| job_data['name'] }
+ end.flatten
+
+ expect(job_names).to contain_exactly('Job 1', 'Job 2')
+ end
+ end
+
+ describe '.jobs(securityReportTypes)' do
+ let_it_be(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipelines {
+ nodes {
+ jobs(securityReportTypes: [SAST]) {
+ nodes {
+ name
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ it 'fetches the jobs matching the report type filter' do
+ pipeline = create(:ci_pipeline, project: project)
+ create(:ci_build, :dast, name: 'DAST Job 1', pipeline: pipeline)
+ create(:ci_build, :sast, name: 'SAST Job 1', pipeline: pipeline)
+
+ post_graphql(query, current_user: first_user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ pipelines_data = graphql_data.dig('project', 'pipelines', 'nodes')
+
+ job_names = pipelines_data.map do |pipeline_data|
+ jobs_data = pipeline_data.dig('jobs', 'nodes')
+ jobs_data.map { |job_data| job_data['name'] }
+ end.flatten
+
+ expect(job_names).to contain_exactly('SAST Job 1')
+ end
+ end
+
+ describe 'upstream' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: first_user) }
+ let_it_be(:upstream_project) { create(:project, :repository, :public) }
+ let_it_be(:upstream_pipeline) { create(:ci_pipeline, project: upstream_project, user: first_user) }
+ let(:upstream_pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]).first['upstream'] }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipelines {
+ nodes {
+ upstream {
+ iid
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ before do
+ create(:ci_sources_pipeline, source_pipeline: upstream_pipeline, pipeline: pipeline )
+
+ post_graphql(query, current_user: first_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns the upstream pipeline of a pipeline' do
+ expect(upstream_pipelines_graphql_data['iid'].to_i).to eq(upstream_pipeline.iid)
+ end
+
+ context 'when fetching the upstream pipeline from the pipeline' do
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ post_graphql(query, current_user: first_user)
+ end
+
+ pipeline_2 = create(:ci_pipeline, project: project, user: first_user)
+ upstream_pipeline_2 = create(:ci_pipeline, project: upstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: upstream_pipeline_2, pipeline: pipeline_2 )
+ pipeline_3 = create(:ci_pipeline, project: project, user: first_user)
+ upstream_pipeline_3 = create(:ci_pipeline, project: upstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: upstream_pipeline_3, pipeline: pipeline_3 )
+
+ expect do
+ post_graphql(query, current_user: second_user)
+ end.not_to exceed_query_limit(control_count)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ describe 'downstream' do
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project, user: first_user) }
+ let(:pipeline_2) { create(:ci_pipeline, project: project, user: first_user) }
+
+ let_it_be(:downstream_project) { create(:project, :repository, :public) }
+ let_it_be(:downstream_pipeline_a) { create(:ci_pipeline, project: downstream_project, user: first_user) }
+ let_it_be(:downstream_pipeline_b) { create(:ci_pipeline, project: downstream_project, user: first_user) }
+
+ let(:pipelines_graphql_data) { graphql_data.dig(*%w[project pipelines nodes]) }
+
+ let(:query) do
+ %(
+ query {
+ project(fullPath: "#{project.full_path}") {
+ pipelines {
+ nodes {
+ downstream {
+ nodes {
+ iid
+ }
+ }
+ }
+ }
+ }
+ }
+ )
+ end
+
+ before do
+ create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downstream_pipeline_a)
+ create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downstream_pipeline_b)
+
+ post_graphql(query, current_user: first_user)
+ end
+
+ it_behaves_like 'a working graphql query'
+
+ it 'returns the downstream pipelines of a pipeline' do
+ downstream_pipelines_graphql_data = pipelines_graphql_data.map { |pip| pip['downstream']['nodes'] }.flatten
+
+ expect(
+ downstream_pipelines_graphql_data.map { |pip| pip['iid'].to_i }
+ ).to contain_exactly(downstream_pipeline_a.iid, downstream_pipeline_b.iid)
+ end
+
+ context 'when fetching the downstream pipelines from the pipeline' do
+ it 'avoids N+1 queries' do
+ control_count = ActiveRecord::QueryRecorder.new do
+ post_graphql(query, current_user: first_user)
+ end
+
+ downstream_pipeline_2a = create(:ci_pipeline, project: downstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downstream_pipeline_2a)
+ downsteam_pipeline_3a = create(:ci_pipeline, project: downstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: pipeline, pipeline: downsteam_pipeline_3a)
+
+ downstream_pipeline_2b = create(:ci_pipeline, project: downstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downstream_pipeline_2b)
+ downsteam_pipeline_3b = create(:ci_pipeline, project: downstream_project, user: first_user)
+ create(:ci_sources_pipeline, source_pipeline: pipeline_2, pipeline: downsteam_pipeline_3b)
+
+ expect do
+ post_graphql(query, current_user: second_user)
+ end.not_to exceed_query_limit(control_count)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
new file mode 100644
index 00000000000..3c1c63c1670
--- /dev/null
+++ b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
@@ -0,0 +1,108 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'container repository details' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be_with_reload(:project) { create(:project) }
+ let_it_be(:container_repository) { create(:container_repository, project: project) }
+
+ let(:query) do
+ graphql_query_for(
+ 'containerRepository',
+ { id: container_repository_global_id },
+ all_graphql_fields_for('ContainerRepositoryDetails')
+ )
+ end
+
+ let(:user) { project.owner }
+ let(:variables) { {} }
+ let(:tags) { %w(latest tag1 tag2 tag3 tag4 tag5) }
+ let(:container_repository_global_id) { container_repository.to_global_id.to_s }
+ let(:container_repository_details_response) { graphql_data.dig('containerRepository') }
+
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(repository: container_repository.path, tags: tags, with_manifest: true)
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+
+ it 'matches the JSON schema' do
+ expect(container_repository_details_response).to match_schema('graphql/container_repository_details')
+ end
+ end
+
+ context 'with different permissions' do
+ let_it_be(:user) { create(:user) }
+
+ let(:tags_response) { container_repository_details_response.dig('tags', 'nodes') }
+
+ where(:project_visibility, :role, :access_granted, :can_delete) do
+ :private | :maintainer | true | true
+ :private | :developer | true | true
+ :private | :reporter | true | false
+ :private | :guest | false | false
+ :private | :anonymous | false | false
+ :public | :maintainer | true | true
+ :public | :developer | true | true
+ :public | :reporter | true | false
+ :public | :guest | true | false
+ :public | :anonymous | true | false
+ end
+
+ with_them do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility.to_s.upcase, false))
+ project.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(tags_response.size).to eq(tags.size)
+ expect(container_repository_details_response.dig('canDelete')).to eq(can_delete)
+ else
+ expect(container_repository_details_response).to eq(nil)
+ end
+ end
+ end
+ end
+
+ context 'limiting the number of tags' do
+ let(:limit) { 2 }
+ let(:tags_response) { container_repository_details_response.dig('tags', 'edges') }
+ let(:variables) do
+ { id: container_repository_global_id, n: limit }
+ end
+
+ let(:query) do
+ <<~GQL
+ query($id: ID!, $n: Int) {
+ containerRepository(id: $id) {
+ tags(first: $n) {
+ edges {
+ node {
+ #{all_graphql_fields_for('ContainerRepositoryTag')}
+ }
+ }
+ }
+ }
+ }
+ GQL
+ end
+
+ it 'only returns n tags' do
+ subject
+
+ expect(tags_response.size).to eq(limit)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/custom_emoji_query_spec.rb b/spec/requests/api/graphql/custom_emoji_query_spec.rb
new file mode 100644
index 00000000000..d5a423d0eba
--- /dev/null
+++ b/spec/requests/api/graphql/custom_emoji_query_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'getting custom emoji within namespace' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:custom_emoji) { create(:custom_emoji, group: group) }
+
+ before do
+ stub_feature_flags(custom_emoji: true)
+ group.add_developer(current_user)
+ end
+
+ describe "Query CustomEmoji on Group" do
+ def custom_emoji_query(group)
+ graphql_query_for('group', 'fullPath' => group.full_path)
+ end
+
+ it 'returns emojis when authorised' do
+ post_graphql(custom_emoji_query(group), current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(graphql_data['group']['customEmoji']['nodes'].count). to eq(1)
+ expect(graphql_data['group']['customEmoji']['nodes'].first['name']). to eq(custom_emoji.name)
+ end
+
+ it 'returns nil when unauthorised' do
+ user = create(:user)
+ post_graphql(custom_emoji_query(group), current_user: user)
+
+ expect(graphql_data['group']).to be_nil
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/group/container_repositories_spec.rb b/spec/requests/api/graphql/group/container_repositories_spec.rb
new file mode 100644
index 00000000000..bcf689a5e8f
--- /dev/null
+++ b/spec/requests/api/graphql/group/container_repositories_spec.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting container repositories in a group' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be(:owner) { create(:user) }
+ let_it_be_with_reload(:group) { create(:group) }
+ let_it_be_with_reload(:project) { create(:project, group: group) }
+ let_it_be(:container_repository) { create(:container_repository, project: project) }
+ let_it_be(:container_repositories_delete_scheduled) { create_list(:container_repository, 2, :status_delete_scheduled, project: project) }
+ let_it_be(:container_repositories_delete_failed) { create_list(:container_repository, 2, :status_delete_failed, project: project) }
+ let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten }
+ let_it_be(:container_expiration_policy) { project.container_expiration_policy }
+
+ let(:fields) do
+ <<~GQL
+ edges {
+ node {
+ #{all_graphql_fields_for('container_repositories'.classify)}
+ }
+ }
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'group',
+ { 'fullPath' => group.full_path },
+ query_graphql_field('container_repositories', {}, fields)
+ )
+ end
+
+ let(:user) { owner }
+ let(:variables) { {} }
+ let(:container_repositories_response) { graphql_data.dig('group', 'containerRepositories', 'edges') }
+
+ before do
+ group.add_owner(owner)
+ stub_container_registry_config(enabled: true)
+ container_repositories.each do |repository|
+ stub_container_registry_tags(repository: repository.path, tags: %w(tag1 tag2 tag3), with_manifest: false)
+ end
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+ end
+
+ context 'with different permissions' do
+ let_it_be(:user) { create(:user) }
+
+ where(:group_visibility, :role, :access_granted, :can_delete) do
+ :private | :maintainer | true | true
+ :private | :developer | true | true
+ :private | :reporter | true | false
+ :private | :guest | false | false
+ :private | :anonymous | false | false
+ :public | :maintainer | true | true
+ :public | :developer | true | true
+ :public | :reporter | true | false
+ :public | :guest | false | false
+ :public | :anonymous | false | false
+ end
+
+ with_them do
+ before do
+ group.update!(visibility_level: Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(group_visibility.to_s.upcase, false))
+
+ group.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(container_repositories_response.size).to eq(container_repositories.size)
+ container_repositories_response.each do |repository_response|
+ expect(repository_response.dig('node', 'canDelete')).to eq(can_delete)
+ end
+ else
+ expect(container_repositories_response).to eq(nil)
+ end
+ end
+ end
+ end
+
+ context 'limiting the number of repositories' do
+ let(:limit) { 1 }
+ let(:variables) do
+ { path: group.full_path, n: limit }
+ end
+
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $n: Int) {
+ group(fullPath: $path) {
+ containerRepositories(first: $n) { #{fields} }
+ }
+ }
+ GQL
+ end
+
+ it 'only returns N repositories' do
+ subject
+
+ expect(container_repositories_response.size).to eq(limit)
+ end
+ end
+
+ context 'filter by name' do
+ let_it_be(:container_repository) { create(:container_repository, name: 'fooBar', project: project) }
+
+ let(:name) { 'ooba' }
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $name: String) {
+ group(fullPath: $path) {
+ containerRepositories(name: $name) { #{fields} }
+ }
+ }
+ GQL
+ end
+
+ let(:variables) do
+ { path: group.full_path, name: name }
+ end
+
+ before do
+ stub_container_registry_tags(repository: container_repository.path, tags: %w(tag4 tag5 tag6), with_manifest: false)
+ end
+
+ it 'returns the searched container repository' do
+ subject
+
+ expect(container_repositories_response.size).to eq(1)
+ expect(container_repositories_response.first.dig('node', 'id')).to eq(container_repository.to_global_id.to_s)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb b/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
index 5d7dbcf2e3c..eb73dc59253 100644
--- a/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
+++ b/spec/requests/api/graphql/instance_statistics_measurements_spec.rb
@@ -9,7 +9,8 @@ RSpec.describe 'InstanceStatisticsMeasurements' do
let!(:instance_statistics_measurement_1) { create(:instance_statistics_measurement, :project_count, recorded_at: 20.days.ago, count: 5) }
let!(:instance_statistics_measurement_2) { create(:instance_statistics_measurement, :project_count, recorded_at: 10.days.ago, count: 10) }
- let(:query) { graphql_query_for(:instanceStatisticsMeasurements, 'identifier: PROJECTS', 'nodes { count identifier }') }
+ let(:arguments) { 'identifier: PROJECTS' }
+ let(:query) { graphql_query_for(:instanceStatisticsMeasurements, arguments, 'nodes { count identifier }') }
before do
post_graphql(query, current_user: current_user)
@@ -21,4 +22,14 @@ RSpec.describe 'InstanceStatisticsMeasurements' do
{ "count" => 5, 'identifier' => 'PROJECTS' }
])
end
+
+ context 'with recorded_at filters' do
+ let(:arguments) { %(identifier: PROJECTS, recordedAfter: "#{15.days.ago.to_date}", recordedBefore: "#{5.days.ago.to_date}") }
+
+ it 'returns filtered measurement objects' do
+ expect(graphql_data.dig('instanceStatisticsMeasurements', 'nodes')).to eq([
+ { "count" => 10, 'identifier' => 'PROJECTS' }
+ ])
+ end
+ end
end
diff --git a/spec/requests/api/graphql/issue/issue_spec.rb b/spec/requests/api/graphql/issue/issue_spec.rb
index 1c9d6b25856..d7fa680d29b 100644
--- a/spec/requests/api/graphql/issue/issue_spec.rb
+++ b/spec/requests/api/graphql/issue/issue_spec.rb
@@ -71,14 +71,34 @@ RSpec.describe 'Query.issue(id)' do
end
context 'selecting multiple fields' do
- let(:issue_fields) { %w(title description) }
+ let(:issue_fields) { ['title', 'description', 'updatedBy { username }'] }
it 'returns the Issue with the specified fields' do
post_graphql(query, current_user: current_user)
- expect(issue_data.keys).to eq( %w(title description) )
+ expect(issue_data.keys).to eq( %w(title description updatedBy) )
expect(issue_data['title']).to eq(issue.title)
expect(issue_data['description']).to eq(issue.description)
+ expect(issue_data['updatedBy']['username']).to eq(issue.author.username)
+ end
+ end
+
+ context 'when issue got moved' do
+ let_it_be(:issue_fields) { ['moved', 'movedTo { title }'] }
+ let_it_be(:new_issue) { create(:issue) }
+ let_it_be(:issue) { create(:issue, project: project, moved_to: new_issue) }
+ let_it_be(:issue_params) { { 'id' => issue.to_global_id.to_s } }
+
+ before_all do
+ new_issue.project.add_developer(current_user)
+ end
+
+ it 'returns correct attributes' do
+ post_graphql(query, current_user: current_user)
+
+ expect(issue_data.keys).to eq( %w(moved movedTo) )
+ expect(issue_data['moved']).to eq(true)
+ expect(issue_data['movedTo']['title']).to eq(new_issue.title)
end
end
diff --git a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
index ca5a9165760..72ec2b8e070 100644
--- a/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
+++ b/spec/requests/api/graphql/metrics/dashboard/annotations_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe 'Getting Metrics Dashboard Annotations' do
let_it_be(:to_old_annotation) do
create(:metrics_dashboard_annotation, environment: environment, starting_at: Time.parse(from).advance(minutes: -5), dashboard_path: path)
end
+
let_it_be(:to_new_annotation) do
create(:metrics_dashboard_annotation, environment: environment, starting_at: to.advance(minutes: 5), dashboard_path: path)
end
diff --git a/spec/requests/api/graphql/mutations/alert_management/http_integration/create_spec.rb b/spec/requests/api/graphql/mutations/alert_management/http_integration/create_spec.rb
new file mode 100644
index 00000000000..a285cebc805
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/http_integration/create_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating a new HTTP Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:variables) do
+ {
+ project_path: project.full_path,
+ active: false,
+ name: 'New HTTP Integration'
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:http_integration_create, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ type
+ name
+ active
+ token
+ url
+ apiUrl
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:http_integration_create) }
+
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'creates a new integration' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ new_integration = ::AlertManagement::HttpIntegration.last!
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(new_integration).to_s)
+ expect(integration_response['type']).to eq('HTTP')
+ expect(integration_response['name']).to eq(new_integration.name)
+ expect(integration_response['active']).to eq(new_integration.active)
+ expect(integration_response['token']).to eq(new_integration.token)
+ expect(integration_response['url']).to eq(new_integration.url)
+ expect(integration_response['apiUrl']).to eq(nil)
+ end
+
+ [:project_path, :active, :name].each do |argument|
+ context "without required argument #{argument}" do
+ before do
+ variables.delete(argument)
+ end
+
+ it_behaves_like 'an invalid argument to the mutation', argument_name: argument
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/http_integration/destroy_spec.rb b/spec/requests/api/graphql/mutations/alert_management/http_integration/destroy_spec.rb
new file mode 100644
index 00000000000..1ecb5c76b57
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/http_integration/destroy_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Removing an HTTP Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(integration).to_s
+ }
+ graphql_mutation(:http_integration_destroy, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ type
+ name
+ active
+ token
+ url
+ apiUrl
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:http_integration_destroy) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'removes the integration' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['type']).to eq('HTTP')
+ expect(integration_response['name']).to eq(integration.name)
+ expect(integration_response['active']).to eq(integration.active)
+ expect(integration_response['token']).to eq(integration.token)
+ expect(integration_response['url']).to eq(integration.url)
+ expect(integration_response['apiUrl']).to eq(nil)
+
+ expect { integration.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/http_integration/reset_token_spec.rb b/spec/requests/api/graphql/mutations/alert_management/http_integration/reset_token_spec.rb
new file mode 100644
index 00000000000..badd9412589
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/http_integration/reset_token_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Resetting a token on an existing HTTP Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(integration).to_s
+ }
+ graphql_mutation(:http_integration_reset_token, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ token
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:http_integration_reset_token) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates the integration' do
+ previous_token = integration.token
+
+ post_graphql_mutation(mutation, current_user: user)
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['token']).not_to eq(previous_token)
+ expect(integration_response['token']).to eq(integration.reload.token)
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/http_integration/update_spec.rb b/spec/requests/api/graphql/mutations/alert_management/http_integration/update_spec.rb
new file mode 100644
index 00000000000..bf7eb3d980c
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/http_integration/update_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating an existing HTTP Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:alert_management_http_integration, project: project) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(integration).to_s,
+ name: 'Modified Name',
+ active: false
+ }
+ graphql_mutation(:http_integration_update, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ name
+ active
+ url
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:http_integration_update) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates the integration' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['name']).to eq('Modified Name')
+ expect(integration_response['active']).to be_falsey
+ expect(integration_response['url']).to include('modified-name')
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
new file mode 100644
index 00000000000..0ef61ae0d5b
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/create_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creating a new Prometheus Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let(:variables) do
+ {
+ project_path: project.full_path,
+ active: false,
+ api_url: 'https://prometheus-url.com'
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:prometheus_integration_create, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ type
+ name
+ active
+ token
+ url
+ apiUrl
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:prometheus_integration_create) }
+
+ before do
+ project.add_maintainer(current_user)
+ end
+
+ it 'creates a new integration' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ new_integration = ::PrometheusService.last!
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(new_integration).to_s)
+ expect(integration_response['type']).to eq('PROMETHEUS')
+ expect(integration_response['name']).to eq(new_integration.title)
+ expect(integration_response['active']).to eq(new_integration.manual_configuration?)
+ expect(integration_response['token']).to eq(new_integration.project.alerting_setting.token)
+ expect(integration_response['url']).to eq("http://localhost/#{project.full_path}/prometheus/alerts/notify.json")
+ expect(integration_response['apiUrl']).to eq(new_integration.api_url)
+ end
+
+ [:project_path, :active, :api_url].each do |argument|
+ context "without required argument #{argument}" do
+ before do
+ variables.delete(argument)
+ end
+
+ it_behaves_like 'an invalid argument to the mutation', argument_name: argument
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
new file mode 100644
index 00000000000..d8d0ace5981
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/reset_token_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Resetting a token on an existing Prometheus Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:prometheus_service, project: project) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(integration).to_s
+ }
+ graphql_mutation(:prometheus_integration_reset_token, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ token
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:prometheus_integration_reset_token) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'creates a token' do
+ post_graphql_mutation(mutation, current_user: user)
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['token']).not_to be_nil
+ expect(integration_response['token']).to eq(project.alerting_setting.token)
+ end
+
+ context 'with an existing alerting setting' do
+ let_it_be(:alerting_setting) { create(:project_alerting_setting, project: project) }
+
+ it 'updates the token' do
+ previous_token = alerting_setting.token
+
+ post_graphql_mutation(mutation, current_user: user)
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['token']).not_to eq(previous_token)
+ expect(integration_response['token']).to eq(alerting_setting.reload.token)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
new file mode 100644
index 00000000000..6c4a647a353
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/alert_management/prometheus_integration/update_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Updating an existing Prometheus Integration' do
+ include GraphqlHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:integration) { create(:prometheus_service, project: project) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(integration).to_s,
+ api_url: 'http://modified-url.com',
+ active: true
+ }
+ graphql_mutation(:prometheus_integration_update, variables) do
+ <<~QL
+ clientMutationId
+ errors
+ integration {
+ id
+ active
+ apiUrl
+ }
+ QL
+ end
+ end
+
+ let(:mutation_response) { graphql_mutation_response(:prometheus_integration_update) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates the integration' do
+ post_graphql_mutation(mutation, current_user: user)
+
+ integration_response = mutation_response['integration']
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(integration_response['id']).to eq(GitlabSchema.id_from_object(integration).to_s)
+ expect(integration_response['apiUrl']).to eq('http://modified-url.com')
+ expect(integration_response['active']).to be_truthy
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/commits/create_spec.rb b/spec/requests/api/graphql/mutations/commits/create_spec.rb
index ac4fa7cfe83..375d4f10b40 100644
--- a/spec/requests/api/graphql/mutations/commits/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/commits/create_spec.rb
@@ -23,6 +23,18 @@ RSpec.describe 'Creation of a new commit' do
let(:mutation) { graphql_mutation(:commit_create, input) }
let(:mutation_response) { graphql_mutation_response(:commit_create) }
+ shared_examples 'a commit is successful' do
+ it 'creates a new commit' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+
+ expect(mutation_response['commit']).to include(
+ 'title' => message
+ )
+ end
+ end
+
context 'the user is not allowed to create a commit' do
it_behaves_like 'a mutation that returns a top-level access error'
end
@@ -32,14 +44,7 @@ RSpec.describe 'Creation of a new commit' do
project.add_developer(current_user)
end
- it 'creates a new commit' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(response).to have_gitlab_http_status(:success)
- expect(mutation_response['commit']).to include(
- 'title' => message
- )
- end
+ it_behaves_like 'a commit is successful'
context 'when branch is not correct' do
let(:branch) { 'unknown' }
@@ -47,5 +52,22 @@ RSpec.describe 'Creation of a new commit' do
it_behaves_like 'a mutation that returns errors in the response',
errors: ['You can only create or edit files when you are on a branch']
end
+
+ context 'when branch is new, and a start_branch is defined' do
+ let(:input) { { project_path: project.full_path, branch: branch, start_branch: start_branch, message: message, actions: actions } }
+ let(:branch) { 'new-branch' }
+ let(:start_branch) { 'master' }
+ let(:actions) do
+ [
+ {
+ action: 'CREATE',
+ filePath: 'ANOTHER_FILE.md',
+ content: 'Bye'
+ }
+ ]
+ end
+
+ it_behaves_like 'a commit is successful'
+ end
end
end
diff --git a/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb b/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
index 7bef812bfec..23e8e366483 100644
--- a/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/container_expiration_policy/update_spec.rb
@@ -73,6 +73,29 @@ RSpec.describe 'Updating the container expiration policy' do
end
end
+ RSpec.shared_examples 'rejecting blank name_regex when enabled' do
+ context "for blank name_regex" do
+ let(:params) do
+ {
+ project_path: project.full_path,
+ name_regex: '',
+ enabled: true
+ }
+ end
+
+ it_behaves_like 'returning response status', :success
+
+ it_behaves_like 'not creating the container expiration policy'
+
+ it 'returns an error' do
+ subject
+
+ expect(graphql_data['updateContainerExpirationPolicy']['errors'].size).to eq(1)
+ expect(graphql_data['updateContainerExpirationPolicy']['errors']).to include("Name regex can't be blank")
+ end
+ end
+ end
+
RSpec.shared_examples 'accepting the mutation request updating the container expiration policy' do
it_behaves_like 'updating the container expiration policy attributes', mode: :update, from: { cadence: '1d', keep_n: 10, older_than: '90d' }, to: { cadence: '3month', keep_n: 100, older_than: '14d' }
@@ -80,6 +103,7 @@ RSpec.describe 'Updating the container expiration policy' do
it_behaves_like 'rejecting invalid regex for', :name_regex
it_behaves_like 'rejecting invalid regex for', :name_regex_keep
+ it_behaves_like 'rejecting blank name_regex when enabled'
end
RSpec.shared_examples 'accepting the mutation request creating the container expiration policy' do
@@ -89,6 +113,7 @@ RSpec.describe 'Updating the container expiration policy' do
it_behaves_like 'rejecting invalid regex for', :name_regex
it_behaves_like 'rejecting invalid regex for', :name_regex_keep
+ it_behaves_like 'rejecting blank name_regex when enabled'
end
RSpec.shared_examples 'denying the mutation request' do
diff --git a/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb
new file mode 100644
index 00000000000..645edfc2e43
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/container_repository/destroy_spec.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Destroying a container repository' do
+ using RSpec::Parameterized::TableSyntax
+
+ include GraphqlHelpers
+
+ let_it_be_with_reload(:container_repository) { create(:container_repository) }
+ let_it_be(:user) { create(:user) }
+
+ let(:project) { container_repository.project }
+ let(:id) { container_repository.to_global_id.to_s }
+
+ let(:query) do
+ <<~GQL
+ containerRepository {
+ #{all_graphql_fields_for('ContainerRepository')}
+ }
+ errors
+ GQL
+ end
+
+ let(:params) { { id: container_repository.to_global_id.to_s } }
+ let(:mutation) { graphql_mutation(:destroy_container_repository, params, query) }
+ let(:mutation_response) { graphql_mutation_response(:destroyContainerRepository) }
+ let(:container_repository_mutation_response) { mutation_response['containerRepository'] }
+
+ before do
+ stub_container_registry_config(enabled: true)
+ stub_container_registry_tags(tags: %w[a b c])
+ end
+
+ shared_examples 'destroying the container repository' do
+ it 'destroy the container repository' do
+ expect(::Packages::CreateEventService)
+ .to receive(:new).with(nil, user, event_name: :delete_repository, scope: :container).and_call_original
+ expect(DeleteContainerRepositoryWorker)
+ .to receive(:perform_async).with(user.id, container_repository.id)
+
+ expect { subject }.to change { ::Packages::Event.count }.by(1)
+
+ expect(container_repository_mutation_response).to match_schema('graphql/container_repository')
+ expect(container_repository_mutation_response['status']).to eq('DELETE_SCHEDULED')
+ end
+
+ it_behaves_like 'returning response status', :success
+ end
+
+ shared_examples 'denying the mutation request' do
+ it 'does not destroy the container repository' do
+ expect(DeleteContainerRepositoryWorker)
+ .not_to receive(:perform_async).with(user.id, container_repository.id)
+
+ expect { subject }.not_to change { ::Packages::Event.count }
+
+ expect(mutation_response).to be_nil
+ end
+
+ it_behaves_like 'returning response status', :success
+ end
+
+ describe 'post graphql mutation' do
+ subject { post_graphql_mutation(mutation, current_user: user) }
+
+ context 'with valid id' do
+ where(:user_role, :shared_examples_name) do
+ :maintainer | 'destroying the container repository'
+ :developer | 'destroying the container repository'
+ :reporter | 'denying the mutation request'
+ :guest | 'denying the mutation request'
+ :anonymous | 'denying the mutation request'
+ end
+
+ with_them do
+ before do
+ project.send("add_#{user_role}", user) unless user_role == :anonymous
+ end
+
+ it_behaves_like params[:shared_examples_name]
+ end
+ end
+
+ context 'with invalid id' do
+ let(:params) { { id: 'gid://gitlab/ContainerRepository/5555' } }
+
+ it_behaves_like 'denying the mutation request'
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb
new file mode 100644
index 00000000000..c91437fa355
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/custom_emoji/create_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creation of a new Custom Emoji' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+
+ let(:attributes) do
+ {
+ name: 'my_new_emoji',
+ url: 'https://example.com/image.png',
+ group_path: group.full_path
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:create_custom_emoji, attributes)
+ end
+
+ context 'when the user has no permission' do
+ it 'does not create custom emoji' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(CustomEmoji, :count)
+ end
+ end
+
+ context 'when user has permission' do
+ before do
+ group.add_developer(current_user)
+ end
+
+ it 'creates custom emoji' do
+ expect { post_graphql_mutation(mutation, current_user: current_user) }.to change(CustomEmoji, :count).by(1)
+
+ gql_response = graphql_mutation_response(:create_custom_emoji)
+ expect(gql_response['errors']).to eq([])
+ expect(gql_response['customEmoji']['name']).to eq(attributes[:name])
+ expect(gql_response['customEmoji']['url']).to eq(attributes[:url])
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/labels/create_spec.rb b/spec/requests/api/graphql/mutations/labels/create_spec.rb
new file mode 100644
index 00000000000..28284408306
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/labels/create_spec.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Mutations::Labels::Create do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+
+ let(:params) do
+ {
+ 'title' => 'foo',
+ 'description' => 'some description',
+ 'color' => '#FF0000'
+ }
+ end
+
+ let(:mutation) { graphql_mutation(:label_create, params.merge(extra_params)) }
+
+ subject { post_graphql_mutation(mutation, current_user: current_user) }
+
+ def mutation_response
+ graphql_mutation_response(:label_create)
+ end
+
+ shared_examples_for 'labels create mutation' do
+ context 'when the user does not have permission to create a label' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+
+ it 'does not create the label' do
+ expect { subject }.not_to change { Label.count }
+ end
+ end
+
+ context 'when the user has permission to create a label' do
+ before do
+ parent.add_developer(current_user)
+ end
+
+ context 'when the parent (project_path or group_path) param is given' do
+ it 'creates the label' do
+ expect { subject }.to change { Label.count }.to(1)
+
+ expect(mutation_response).to include(
+ 'label' => a_hash_including(params))
+ end
+
+ it 'does not create a label when there are errors' do
+ label_factory = parent.is_a?(Group) ? :group_label : :label
+ create(label_factory, title: 'foo', parent.class.name.underscore.to_sym => parent)
+
+ expect { subject }.not_to change { Label.count }
+
+ expect(mutation_response).to have_key('label')
+ expect(mutation_response['label']).to be_nil
+ expect(mutation_response['errors'].first).to eq('Title has already been taken')
+ end
+ end
+ end
+ end
+
+ context 'when creating a project label' do
+ let_it_be(:parent) { create(:project) }
+ let(:extra_params) { { project_path: parent.full_path } }
+
+ it_behaves_like 'labels create mutation'
+ end
+
+ context 'when creating a group label' do
+ let_it_be(:parent) { create(:group) }
+ let(:extra_params) { { group_path: parent.full_path } }
+
+ it_behaves_like 'labels create mutation'
+ end
+
+ context 'when neither project_path nor group_path param is given' do
+ let(:mutation) { graphql_mutation(:label_create, params) }
+
+ it_behaves_like 'a mutation that returns top-level errors',
+ errors: ['Exactly one of group_path or project_path arguments is required']
+
+ it 'does not create the label' do
+ expect { subject }.not_to change { Label.count }
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
index 81d13b29dde..2a39757e103 100644
--- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
+++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/create_spec.rb
@@ -101,9 +101,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create do
graphql_mutation(:create_annotation, variables)
end
- it_behaves_like 'a mutation that returns top-level errors' do
- let(:match_errors) { include(/is not a valid Global ID/) }
- end
+ it_behaves_like 'an invalid argument to the mutation', argument_name: :environment_id
end
end
end
@@ -190,9 +188,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create do
graphql_mutation(:create_annotation, variables)
end
- it_behaves_like 'a mutation that returns top-level errors' do
- let(:match_errors) { include(/is not a valid Global ID/) }
- end
+ it_behaves_like 'an invalid argument to the mutation', argument_name: :cluster_id
end
end
@@ -213,35 +209,26 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Create do
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(:gid) { { environment_id: project.to_global_id.to_s } }
- let(:mutation) do
- variables = {
- starting_at: starting_at,
- ending_at: ending_at,
- dashboard_path: dashboard_path,
- description: description
- }.merge!(gid)
-
- graphql_mutation(:create_annotation, variables)
- end
-
- before do
- project.add_developer(current_user)
- end
+ [:environment_id, :cluster_id].each do |arg_name|
+ context "when #{arg_name} is given an ID of the wrong type" do
+ let(:gid) { global_id_of(project) }
+ let(:mutation) do
+ variables = {
+ starting_at: starting_at,
+ ending_at: ending_at,
+ dashboard_path: dashboard_path,
+ description: description,
+ arg_name => gid
+ }
- describe 'non-environment id' do
- it_behaves_like 'a mutation that returns top-level errors' do
- let(:match_errors) { include(/does not represent an instance of Environment/) }
+ graphql_mutation(:create_annotation, variables)
end
- end
-
- describe 'non-cluster id' do
- let(:gid) { { cluster_id: project.to_global_id.to_s } }
- it_behaves_like 'a mutation that returns top-level errors' do
- let(:match_errors) { include(/does not represent an instance of Clusters::Cluster/) }
+ before do
+ project.add_developer(current_user)
end
+
+ it_behaves_like 'an invalid argument to the mutation', argument_name: arg_name
end
end
end
diff --git a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
index 9a612c841a2..b956734068c 100644
--- a/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
+++ b/spec/requests/api/graphql/mutations/metrics/dashboard/annotations/delete_spec.rb
@@ -36,7 +36,7 @@ RSpec.describe Mutations::Metrics::Dashboard::Annotations::Delete do
let(:variables) { { id: GitlabSchema.id_from_object(project).to_s } }
it_behaves_like 'a mutation that returns top-level errors' do
- let(:match_errors) { eq(["#{variables[:id]} is not a valid ID for #{annotation.class}."]) }
+ let(:match_errors) { contain_exactly(include('invalid value for id')) }
end
end
diff --git a/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb
new file mode 100644
index 00000000000..4efa7f9d509
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/notes/reposition_image_diff_note_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Repositioning an ImageDiffNote' do
+ include GraphqlHelpers
+
+ let_it_be(:noteable) { create(:merge_request) }
+ let_it_be(:project) { noteable.project }
+ let(:note) { create(:image_diff_note_on_merge_request, noteable: noteable, project: project) }
+ let(:new_position) { { x: 10 } }
+ let(:current_user) { project.creator }
+
+ let(:mutation_variables) do
+ {
+ id: global_id_of(note),
+ position: new_position
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(:reposition_image_diff_note, mutation_variables) do
+ <<~QL
+ note {
+ id
+ }
+ errors
+ QL
+ end
+ end
+
+ def mutation_response
+ graphql_mutation_response(:reposition_image_diff_note)
+ end
+
+ it 'updates the note', :aggregate_failures do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.to change { note.reset.position.x }.to(10)
+
+ expect(mutation_response['note']).to eq('id' => global_id_of(note))
+ expect(mutation_response['errors']).to be_empty
+ end
+
+ context 'when the note is not a DiffNote' do
+ let(:note) { project }
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/does not represent an instance of DiffNote/) }
+ end
+ end
+
+ context 'when a position arg is nil' do
+ let(:new_position) { { x: nil, y: 10 } }
+
+ it 'does not set the property to nil', :aggregate_failures do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.not_to change { note.reset.position.x }
+
+ expect(mutation_response['note']).to eq('id' => global_id_of(note))
+ expect(mutation_response['errors']).to be_empty
+ end
+ end
+
+ context 'when all position args are nil' do
+ let(:new_position) { { x: nil } }
+
+ it_behaves_like 'a mutation that returns top-level errors' do
+ let(:match_errors) { include(/RepositionImageDiffNoteInput! was provided invalid value/) }
+ end
+
+ it 'contains an explanation for the error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ explanation = graphql_errors.first['extensions']['problems'].first['explanation']
+
+ expect(explanation).to eq('At least one property of `UpdateDiffImagePositionInput` must be set')
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb b/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
index efa2ceb65c2..713b26a6a9b 100644
--- a/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
+++ b/spec/requests/api/graphql/mutations/notes/update/image_diff_note_spec.rb
@@ -20,6 +20,7 @@ RSpec.describe 'Updating an image DiffNote' do
position_type: 'image'
)
end
+
let_it_be(:updated_body) { 'Updated body' }
let_it_be(:updated_width) { 50 }
let_it_be(:updated_height) { 100 }
@@ -31,7 +32,7 @@ RSpec.describe 'Updating an image DiffNote' do
height: updated_height,
x: updated_x,
y: updated_y
- }
+ }.compact.presence
end
let!(:diff_note) do
@@ -45,10 +46,11 @@ RSpec.describe 'Updating an image DiffNote' do
let(:mutation) do
variables = {
id: GitlabSchema.id_from_object(diff_note).to_s,
- body: updated_body,
- position: updated_position
+ body: updated_body
}
+ variables[:position] = updated_position if updated_position
+
graphql_mutation(:update_image_diff_note, variables)
end
diff --git a/spec/requests/api/graphql/mutations/releases/create_spec.rb b/spec/requests/api/graphql/mutations/releases/create_spec.rb
new file mode 100644
index 00000000000..d745eb3083d
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/releases/create_spec.rb
@@ -0,0 +1,375 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Creation of a new release' do
+ include GraphqlHelpers
+ include Presentable
+
+ let_it_be(:project) { create(:project, :public, :repository) }
+ let_it_be(:milestone_12_3) { create(:milestone, project: project, title: '12.3') }
+ let_it_be(:milestone_12_4) { create(:milestone, project: project, title: '12.4') }
+ let_it_be(:public_user) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+
+ let(:mutation_name) { :release_create }
+
+ let(:tag_name) { 'v7.12.5'}
+ let(:ref) { 'master'}
+ let(:name) { 'Version 7.12.5'}
+ let(:description) { 'Release 7.12.5 :rocket:' }
+ let(:released_at) { '2018-12-10' }
+ let(:milestones) { [milestone_12_3.title, milestone_12_4.title] }
+ let(:asset_link) { { name: 'An asset link', url: 'https://gitlab.example.com/link', directAssetPath: '/permanent/link', linkType: 'OTHER' } }
+ let(:assets) { { links: [asset_link] } }
+
+ let(:mutation_arguments) do
+ {
+ projectPath: project.full_path,
+ tagName: tag_name,
+ ref: ref,
+ name: name,
+ description: description,
+ releasedAt: released_at,
+ milestones: milestones,
+ assets: assets
+ }
+ end
+
+ let(:mutation) do
+ graphql_mutation(mutation_name, mutation_arguments, <<~FIELDS)
+ release {
+ tagName
+ name
+ description
+ releasedAt
+ createdAt
+ milestones {
+ nodes {
+ title
+ }
+ }
+ assets {
+ links {
+ nodes {
+ name
+ url
+ linkType
+ external
+ directAssetUrl
+ }
+ }
+ }
+ }
+ errors
+ FIELDS
+ end
+
+ let(:create_release) { post_graphql_mutation(mutation, current_user: current_user) }
+ let(:mutation_response) { graphql_mutation_response(mutation_name)&.with_indifferent_access }
+
+ around do |example|
+ freeze_time { example.run }
+ end
+
+ before do
+ project.add_guest(guest)
+ project.add_reporter(reporter)
+ project.add_developer(developer)
+
+ stub_default_url_options(host: 'www.example.com')
+ end
+
+ shared_examples 'no errors' do
+ it 'returns no errors' do
+ create_release
+
+ expect(graphql_errors).not_to be_present
+ end
+ end
+
+ shared_examples 'top-level error with message' do |error_message|
+ it 'returns a top-level error with message' do
+ create_release
+
+ expect(mutation_response).to be_nil
+ expect(graphql_errors.count).to eq(1)
+ expect(graphql_errors.first['message']).to eq(error_message)
+ end
+ end
+
+ shared_examples 'errors-as-data with message' do |error_message|
+ it 'returns an error-as-data with message' do
+ create_release
+
+ expect(mutation_response[:release]).to be_nil
+ expect(mutation_response[:errors].count).to eq(1)
+ expect(mutation_response[:errors].first).to match(error_message)
+ end
+ end
+
+ context 'when the current user has access to create releases' do
+ let(:current_user) { developer }
+
+ context 'when all available mutation arguments are provided' do
+ it_behaves_like 'no errors'
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ it 'returns the new release data' do
+ create_release
+
+ release = mutation_response[:release]
+ expected_direct_asset_url = Gitlab::Routing.url_helpers.project_release_url(project, Release.find_by(tag: tag_name)) << "/downloads#{asset_link[:directAssetPath]}"
+
+ expected_attributes = {
+ tagName: tag_name,
+ name: name,
+ description: description,
+ releasedAt: Time.parse(released_at).utc.iso8601,
+ createdAt: Time.current.utc.iso8601,
+ assets: {
+ links: {
+ nodes: [{
+ name: asset_link[:name],
+ url: asset_link[:url],
+ linkType: asset_link[:linkType],
+ external: true,
+ directAssetUrl: expected_direct_asset_url
+ }]
+ }
+ }
+ }
+
+ expect(release).to include(expected_attributes)
+
+ # Right now the milestones are returned in a non-deterministic order.
+ # This `milestones` test should be moved up into the expect(release)
+ # above (and `.to include` updated to `.to eq`) once
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/259012 is addressed.
+ expect(release['milestones']['nodes']).to match_array([
+ { 'title' => '12.4' },
+ { 'title' => '12.3' }
+ ])
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+ end
+
+ context 'when only the required mutation arguments are provided' do
+ let(:mutation_arguments) { super().slice(:projectPath, :tagName, :ref) }
+
+ it_behaves_like 'no errors'
+
+ it 'returns the new release data' do
+ create_release
+
+ expected_response = {
+ tagName: tag_name,
+ name: tag_name,
+ description: nil,
+ releasedAt: Time.current.utc.iso8601,
+ createdAt: Time.current.utc.iso8601,
+ milestones: {
+ nodes: []
+ },
+ assets: {
+ links: {
+ nodes: []
+ }
+ }
+ }.with_indifferent_access
+
+ expect(mutation_response[:release]).to eq(expected_response)
+ end
+ end
+
+ context 'when the provided tag already exists' do
+ let(:tag_name) { 'v1.1.0' }
+
+ it_behaves_like 'no errors'
+
+ it 'does not create a new tag' do
+ expect { create_release }.not_to change { Project.find_by_id(project.id).repository.tag_count }
+ end
+ end
+
+ context 'when the provided tag does not already exist' do
+ let(:tag_name) { 'v7.12.5-alpha' }
+
+ it_behaves_like 'no errors'
+
+ it 'creates a new tag' do
+ expect { create_release }.to change { Project.find_by_id(project.id).repository.tag_count }.by(1)
+ end
+ end
+
+ context 'when a local timezone is provided for releasedAt' do
+ let(:released_at) { Time.parse(super()).in_time_zone('Hawaii').iso8601 }
+
+ it_behaves_like 'no errors'
+
+ it 'returns the correct releasedAt date in UTC' do
+ create_release
+
+ expect(mutation_response[:release]).to include({ releasedAt: Time.parse(released_at).utc.iso8601 })
+ end
+ end
+
+ context 'when no releasedAt is provided' do
+ let(:mutation_arguments) { super().except(:releasedAt) }
+
+ it_behaves_like 'no errors'
+
+ it 'sets releasedAt to the current time' do
+ create_release
+
+ expect(mutation_response[:release]).to include({ releasedAt: Time.current.utc.iso8601 })
+ end
+ end
+
+ context "when a release asset doesn't include an explicit linkType" do
+ let(:asset_link) { super().except(:linkType) }
+
+ it_behaves_like 'no errors'
+
+ it 'defaults the linkType to OTHER' do
+ create_release
+
+ returned_asset_link_type = mutation_response.dig(:release, :assets, :links, :nodes, 0, :linkType)
+
+ expect(returned_asset_link_type).to eq('OTHER')
+ end
+ end
+
+ context "when a release asset doesn't include a directAssetPath" do
+ let(:asset_link) { super().except(:directAssetPath) }
+
+ it_behaves_like 'no errors'
+
+ it 'returns the provided url as the directAssetUrl' do
+ create_release
+
+ returned_asset_link_type = mutation_response.dig(:release, :assets, :links, :nodes, 0, :directAssetUrl)
+
+ expect(returned_asset_link_type).to eq(asset_link[:url])
+ end
+ end
+
+ context 'empty milestones' do
+ shared_examples 'no associated milestones' do
+ it_behaves_like 'no errors'
+
+ it 'creates a release with no associated milestones' do
+ create_release
+
+ returned_milestones = mutation_response.dig(:release, :milestones, :nodes)
+
+ expect(returned_milestones.count).to eq(0)
+ end
+ end
+
+ context 'when the milestones parameter is not provided' do
+ let(:mutation_arguments) { super().except(:milestones) }
+
+ it_behaves_like 'no associated milestones'
+ end
+
+ context 'when the milestones parameter is null' do
+ let(:milestones) { nil }
+
+ it_behaves_like 'no associated milestones'
+ end
+
+ context 'when the milestones parameter is an empty array' do
+ let(:milestones) { [] }
+
+ it_behaves_like 'no associated milestones'
+ end
+ end
+
+ context 'validation' do
+ context 'when a release is already associated to the specified tag' do
+ before do
+ create(:release, project: project, tag: tag_name)
+ end
+
+ it_behaves_like 'errors-as-data with message', 'Release already exists'
+ end
+
+ context "when a provided milestone doesn\'t exist" do
+ let(:milestones) { ['a fake milestone'] }
+
+ it_behaves_like 'errors-as-data with message', 'Milestone(s) not found: a fake milestone'
+ end
+
+ context "when a provided milestone belongs to a different project than the release" do
+ let(:milestone_in_different_project) { create(:milestone, title: 'different milestone') }
+ let(:milestones) { [milestone_in_different_project.title] }
+
+ it_behaves_like 'errors-as-data with message', "Milestone(s) not found: different milestone"
+ end
+
+ context 'when two release assets share the same name' do
+ let(:asset_link_1) { { name: 'My link', url: 'https://example.com/1' } }
+ let(:asset_link_2) { { name: 'My link', url: 'https://example.com/2' } }
+ let(:assets) { { links: [asset_link_1, asset_link_2] } }
+
+ # Right now the raw Postgres error message is sent to the user as the validation message.
+ # We should catch this validation error and return a nicer message:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/277087
+ it_behaves_like 'errors-as-data with message', 'PG::UniqueViolation'
+ end
+
+ context 'when two release assets share the same URL' do
+ let(:asset_link_1) { { name: 'My first link', url: 'https://example.com' } }
+ let(:asset_link_2) { { name: 'My second link', url: 'https://example.com' } }
+ let(:assets) { { links: [asset_link_1, asset_link_2] } }
+
+ # Same note as above about the ugly error message
+ it_behaves_like 'errors-as-data with message', 'PG::UniqueViolation'
+ end
+
+ context 'when the provided tag name is HEAD' do
+ let(:tag_name) { 'HEAD' }
+
+ it_behaves_like 'errors-as-data with message', 'Tag name invalid'
+ end
+
+ context 'when the provided tag name is empty' do
+ let(:tag_name) { '' }
+
+ it_behaves_like 'errors-as-data with message', 'Tag name invalid'
+ end
+
+ context "when the provided tag doesn't already exist, and no ref parameter was provided" do
+ let(:ref) { nil }
+ let(:tag_name) { 'v7.12.5-beta' }
+
+ it_behaves_like 'errors-as-data with message', 'Ref is not specified'
+ end
+ end
+ end
+
+ context "when the current user doesn't have access to create releases" do
+ expected_error_message = "The resource that you are attempting to access does not exist or you don't have permission to perform this action"
+
+ context 'when the current user is a Reporter' do
+ let(:current_user) { reporter }
+
+ it_behaves_like 'top-level error with message', expected_error_message
+ end
+
+ context 'when the current user is a Guest' do
+ let(:current_user) { guest }
+
+ it_behaves_like 'top-level error with message', expected_error_message
+ end
+
+ context 'when the current user is a public user' do
+ let(:current_user) { public_user }
+
+ it_behaves_like 'top-level error with message', expected_error_message
+ 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 b71f87d2702..1be8ce142ac 100644
--- a/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
+++ b/spec/requests/api/graphql/mutations/snippets/destroy_spec.rb
@@ -53,10 +53,11 @@ RSpec.describe 'Destroying a Snippet' do
let!(:snippet_gid) { project.to_gid.to_s }
it 'returns an error' do
+ err_message = %Q["#{snippet_gid}" does not represent an instance of Snippet]
+
post_graphql_mutation(mutation, current_user: current_user)
- expect(graphql_errors)
- .to include(a_hash_including('message' => "#{snippet_gid} is not a valid ID for Snippet."))
+ expect(graphql_errors).to include(a_hash_including('message' => a_string_including(err_message)))
end
it 'does not destroy the Snippet' do
diff --git a/spec/requests/api/graphql/mutations/todos/create_spec.rb b/spec/requests/api/graphql/mutations/todos/create_spec.rb
new file mode 100644
index 00000000000..aca00519682
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/todos/create_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Create a todo' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:target) { create(:issue) }
+
+ let(:input) do
+ {
+ 'targetId' => target.to_global_id.to_s
+ }
+ end
+
+ let(:mutation) { graphql_mutation(:todoCreate, input) }
+
+ let(:mutation_response) { graphql_mutation_response(:todoCreate) }
+
+ context 'the user is not allowed to create todo' do
+ it_behaves_like 'a mutation that returns a top-level access error'
+ end
+
+ context 'when user has permissions to create todo' do
+ before do
+ target.project.add_guest(current_user)
+ end
+
+ it 'creates todo' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(mutation_response['todo']['body']).to eq(target.title)
+ expect(mutation_response['todo']['state']).to eq('pending')
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb b/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb
new file mode 100644
index 00000000000..3e96d5c5058
--- /dev/null
+++ b/spec/requests/api/graphql/mutations/todos/restore_many_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Restoring many Todos' do
+ include GraphqlHelpers
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:other_user) { create(:user) }
+
+ let_it_be(:todo1) { create(:todo, user: current_user, author: author, state: :done) }
+ let_it_be(:todo2) { create(:todo, user: current_user, author: author, state: :done) }
+
+ let_it_be(:other_user_todo) { create(:todo, user: other_user, author: author, state: :done) }
+
+ let(:input_ids) { [todo1, todo2].map { |obj| global_id_of(obj) } }
+ let(:input) { { ids: input_ids } }
+
+ let(:mutation) do
+ graphql_mutation(:todo_restore_many, input,
+ <<-QL.strip_heredoc
+ clientMutationId
+ errors
+ updatedIds
+ todos {
+ id
+ state
+ }
+ QL
+ )
+ end
+
+ def mutation_response
+ graphql_mutation_response(:todo_restore_many)
+ end
+
+ it 'restores many todos' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(todo1.reload.state).to eq('pending')
+ expect(todo2.reload.state).to eq('pending')
+ expect(other_user_todo.reload.state).to eq('done')
+
+ expect(mutation_response).to include(
+ 'errors' => be_empty,
+ 'updatedIds' => match_array(input_ids),
+ 'todos' => contain_exactly(
+ { 'id' => global_id_of(todo1), 'state' => 'pending' },
+ { 'id' => global_id_of(todo2), 'state' => 'pending' }
+ )
+ )
+ end
+
+ context 'when using an invalid gid' do
+ let(:input_ids) { [global_id_of(author)] }
+ let(:invalid_gid_error) { /does not represent an instance of #{todo1.class}/ }
+
+ it 'contains the expected error' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ errors = json_response['errors']
+ expect(errors).not_to be_blank
+ expect(errors.first['message']).to match(invalid_gid_error)
+
+ expect(todo1.reload.state).to eq('done')
+ expect(todo2.reload.state).to eq('done')
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb b/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb
index 44e68c59248..37cc502103d 100644
--- a/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb
+++ b/spec/requests/api/graphql/namespace/root_storage_statistics_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe 'rendering namespace statistics' do
include GraphqlHelpers
let(:namespace) { user.namespace }
- let!(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace, packages_size: 5.gigabytes) }
+ let!(:statistics) { create(:namespace_root_storage_statistics, namespace: namespace, packages_size: 5.gigabytes, uploads_size: 3.gigabytes) }
let(:user) { create(:user) }
let(:query) do
@@ -28,6 +28,12 @@ RSpec.describe 'rendering namespace statistics' do
expect(graphql_data['namespace']['rootStorageStatistics']).not_to be_blank
expect(graphql_data['namespace']['rootStorageStatistics']['packagesSize']).to eq(5.gigabytes)
end
+
+ it 'includes uploads size if the user can read the statistics' do
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data_at(:namespace, :root_storage_statistics, :uploads_size)).to eq(3.gigabytes)
+ end
end
it_behaves_like 'a working namespace with storage statistics query'
diff --git a/spec/requests/api/graphql/project/alert_management/integrations_spec.rb b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
new file mode 100644
index 00000000000..b13805a61ce
--- /dev/null
+++ b/spec/requests/api/graphql/project/alert_management/integrations_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting Alert Management Integrations' do
+ include ::Gitlab::Routing
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:prometheus_service) { create(:prometheus_service, project: project) }
+ let_it_be(:project_alerting_setting) { create(:project_alerting_setting, project: project) }
+ let_it_be(:active_http_integration) { create(:alert_management_http_integration, project: project) }
+ let_it_be(:inactive_http_integration) { create(:alert_management_http_integration, :inactive, project: project) }
+ let_it_be(:other_project_http_integration) { create(:alert_management_http_integration) }
+
+ let(:fields) do
+ <<~QUERY
+ nodes {
+ #{all_graphql_fields_for('AlertManagementIntegration')}
+ }
+ QUERY
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('alertManagementIntegrations', {}, fields)
+ )
+ end
+
+ context 'with integrations' do
+ let(:integrations) { graphql_data.dig('project', 'alertManagementIntegrations', '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(integrations).to be_nil }
+ end
+
+ context 'with project permissions' do
+ before do
+ project.add_maintainer(current_user)
+ post_graphql(query, current_user: current_user)
+ end
+
+ let(:http_integration) { integrations.first }
+ let(:prometheus_integration) { integrations.second }
+
+ it_behaves_like 'a working graphql query'
+
+ it { expect(integrations.size).to eq(2) }
+
+ it 'returns the correct properties of the integrations' do
+ expect(http_integration).to include(
+ 'id' => GitlabSchema.id_from_object(active_http_integration).to_s,
+ 'type' => 'HTTP',
+ 'name' => active_http_integration.name,
+ 'active' => active_http_integration.active,
+ 'token' => active_http_integration.token,
+ 'url' => active_http_integration.url,
+ 'apiUrl' => nil
+ )
+
+ expect(prometheus_integration).to include(
+ 'id' => GitlabSchema.id_from_object(prometheus_service).to_s,
+ 'type' => 'PROMETHEUS',
+ 'name' => 'Prometheus',
+ 'active' => prometheus_service.manual_configuration?,
+ 'token' => project_alerting_setting.token,
+ 'url' => "http://localhost/#{project.full_path}/prometheus/alerts/notify.json",
+ 'apiUrl' => prometheus_service.api_url
+ )
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb
new file mode 100644
index 00000000000..7e32f54bf1d
--- /dev/null
+++ b/spec/requests/api/graphql/project/container_repositories_spec.rb
@@ -0,0 +1,145 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe 'getting container repositories in a project' do
+ using RSpec::Parameterized::TableSyntax
+ include GraphqlHelpers
+
+ let_it_be_with_reload(:project) { create(:project, :private) }
+ let_it_be(:container_repository) { create(:container_repository, project: project) }
+ let_it_be(:container_repositories_delete_scheduled) { create_list(:container_repository, 2, :status_delete_scheduled, project: project) }
+ let_it_be(:container_repositories_delete_failed) { create_list(:container_repository, 2, :status_delete_failed, project: project) }
+ let_it_be(:container_repositories) { [container_repository, container_repositories_delete_scheduled, container_repositories_delete_failed].flatten }
+ let_it_be(:container_expiration_policy) { project.container_expiration_policy }
+
+ let(:fields) do
+ <<~GQL
+ edges {
+ node {
+ #{all_graphql_fields_for('container_repositories'.classify)}
+ }
+ }
+ GQL
+ end
+
+ let(:query) do
+ graphql_query_for(
+ 'project',
+ { 'fullPath' => project.full_path },
+ query_graphql_field('container_repositories', {}, fields)
+ )
+ end
+
+ let(:user) { project.owner }
+ let(:variables) { {} }
+ let(:container_repositories_response) { graphql_data.dig('project', 'containerRepositories', 'edges') }
+
+ before do
+ stub_container_registry_config(enabled: true)
+ container_repositories.each do |repository|
+ stub_container_registry_tags(repository: repository.path, tags: %w(tag1 tag2 tag3), with_manifest: false)
+ end
+ end
+
+ subject { post_graphql(query, current_user: user, variables: variables) }
+
+ it_behaves_like 'a working graphql query' do
+ before do
+ subject
+ end
+
+ it 'matches the JSON schema' do
+ expect(container_repositories_response).to match_schema('graphql/container_repositories')
+ end
+ end
+
+ context 'with different permissions' do
+ let_it_be(:user) { create(:user) }
+
+ where(:project_visibility, :role, :access_granted, :can_delete) do
+ :private | :maintainer | true | true
+ :private | :developer | true | true
+ :private | :reporter | true | false
+ :private | :guest | false | false
+ :private | :anonymous | false | false
+ :public | :maintainer | true | true
+ :public | :developer | true | true
+ :public | :reporter | true | false
+ :public | :guest | true | false
+ :public | :anonymous | true | false
+ end
+
+ with_them do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility.to_s.upcase, false))
+ project.add_user(user, role) unless role == :anonymous
+ end
+
+ it 'return the proper response' do
+ subject
+
+ if access_granted
+ expect(container_repositories_response.size).to eq(container_repositories.size)
+ container_repositories_response.each do |repository_response|
+ expect(repository_response.dig('node', 'canDelete')).to eq(can_delete)
+ end
+ else
+ expect(container_repositories_response).to eq(nil)
+ end
+ end
+ end
+ end
+
+ context 'limiting the number of repositories' do
+ let(:limit) { 1 }
+ let(:variables) do
+ { path: project.full_path, n: limit }
+ end
+
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $n: Int) {
+ project(fullPath: $path) {
+ containerRepositories(first: $n) { #{fields} }
+ }
+ }
+ GQL
+ end
+
+ it 'only returns N repositories' do
+ subject
+
+ expect(container_repositories_response.size).to eq(limit)
+ end
+ end
+
+ context 'filter by name' do
+ let_it_be(:container_repository) { create(:container_repository, name: 'fooBar', project: project) }
+
+ let(:name) { 'ooba' }
+ let(:query) do
+ <<~GQL
+ query($path: ID!, $name: String) {
+ project(fullPath: $path) {
+ containerRepositories(name: $name) { #{fields} }
+ }
+ }
+ GQL
+ end
+
+ let(:variables) do
+ { path: project.full_path, name: name }
+ end
+
+ before do
+ stub_container_registry_tags(repository: container_repository.path, tags: %w(tag4 tag5 tag6), with_manifest: false)
+ end
+
+ it 'returns the searched container repository' do
+ subject
+
+ expect(container_repositories_response.size).to eq(1)
+ expect(container_repositories_response.first.dig('node', 'id')).to eq(container_repository.to_global_id.to_s)
+ end
+ end
+end
diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
index cd84ce9cb96..c7d327a62af 100644
--- a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
+++ b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
@@ -29,10 +29,12 @@ RSpec.describe 'sentry errors requests' do
let(:error_data) { graphql_data.dig('project', 'sentryErrors', 'detailedError') }
- it_behaves_like 'a working graphql query' do
- before do
- post_graphql(query, current_user: current_user)
- end
+ it 'returns a successful response', :aggregate_failures, :quarantine do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(graphql_errors).to be_nil
+ expect(json_response.keys).to include('data')
end
context 'when data is loading via reactive cache' do
@@ -191,7 +193,7 @@ RSpec.describe 'sentry errors requests' do
describe 'getting a stack trace' do
let_it_be(:sentry_stack_trace) { build(:error_tracking_error_event) }
- let(:sentry_gid) { Gitlab::ErrorTracking::DetailedError.new(id: 1).to_global_id.to_s }
+ let(:sentry_gid) { global_id_of(Gitlab::ErrorTracking::DetailedError.new(id: 1)) }
let(:stack_trace_fields) do
all_graphql_fields_for('SentryErrorStackTrace'.classify)
diff --git a/spec/requests/api/graphql/project/grafana_integration_spec.rb b/spec/requests/api/graphql/project/grafana_integration_spec.rb
index 688959e622d..9b24698f40c 100644
--- a/spec/requests/api/graphql/project/grafana_integration_spec.rb
+++ b/spec/requests/api/graphql/project/grafana_integration_spec.rb
@@ -45,7 +45,6 @@ RSpec.describe 'Getting Grafana Integration' do
it_behaves_like 'a working graphql query'
- specify { expect(integration_data['token']).to eql grafana_integration.masked_token }
specify { expect(integration_data['grafanaUrl']).to eql grafana_integration.grafana_url }
specify do
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
index 1b654e660e3..4bce3c7fe0f 100644
--- a/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
+++ b/spec/requests/api/graphql/project/issue/design_collection/version_spec.rb
@@ -14,6 +14,7 @@ RSpec.describe 'Query.project(fullPath).issue(iid).designCollection.version(sha)
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,
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
index 640ac95cd86..ee0085718b3 100644
--- a/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb
+++ b/spec/requests/api/graphql/project/issue/design_collection/versions_spec.rb
@@ -11,12 +11,15 @@ RSpec.describe 'Getting versions related to an issue' do
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
diff --git a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
index e25453510d5..a671ddc7ab1 100644
--- a/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
+++ b/spec/requests/api/graphql/project/issue/designs/notes_spec.rb
@@ -30,7 +30,7 @@ RSpec.describe 'Getting designs related to an issue' do
post_graphql(query(note_fields), current_user: nil)
- designs_data = graphql_data['project']['issue']['designs']['designs']
+ designs_data = graphql_data['project']['issue']['designCollection']['designs']
design_data = designs_data['nodes'].first
note_data = design_data['notes']['nodes'].first
@@ -56,7 +56,7 @@ RSpec.describe 'Getting designs related to an issue' do
'issue',
{ iid: design.issue.iid.to_s },
query_graphql_field(
- 'designs', {}, design_node
+ 'designCollection', {}, design_node
)
)
)
diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb
index 40fec6ba068..4f27f08bf98 100644
--- a/spec/requests/api/graphql/project/issues_spec.rb
+++ b/spec/requests/api/graphql/project/issues_spec.rb
@@ -9,10 +9,9 @@ RSpec.describe 'getting an issue list for a project' do
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:issues, reload: true) do
- [create(:issue, project: project, discussion_locked: true),
- create(:issue, :with_alert, project: project)]
- end
+ let_it_be(:issue_a, reload: true) { create(:issue, project: project, discussion_locked: true) }
+ let_it_be(:issue_b, reload: true) { create(:issue, :with_alert, project: project) }
+ let_it_be(:issues, reload: true) { [issue_a, issue_b] }
let(:fields) do
<<~QUERY
@@ -414,4 +413,42 @@ RSpec.describe 'getting an issue list for a project' do
expect(response_assignee_ids(issues_data)).to match_array(assignees_as_global_ids(new_issues))
end
end
+
+ describe 'N+1 query checks' do
+ let(:extra_iid_for_second_query) { issue_b.iid.to_s }
+ let(:search_params) { { iids: [issue_a.iid.to_s] } }
+
+ def execute_query
+ query = graphql_query_for(
+ :project,
+ { full_path: project.full_path },
+ query_graphql_field(:issues, search_params, [
+ query_graphql_field(:nodes, nil, requested_fields)
+ ])
+ )
+ post_graphql(query, current_user: current_user)
+ end
+
+ context 'when requesting `user_notes_count`' do
+ let(:requested_fields) { [:user_notes_count] }
+
+ before do
+ create_list(:note_on_issue, 2, noteable: issue_a, project: project)
+ create(:note_on_issue, noteable: issue_b, project: project)
+ end
+
+ include_examples 'N+1 query check'
+ end
+
+ context 'when requesting `user_discussions_count`' do
+ let(:requested_fields) { [:user_discussions_count] }
+
+ before do
+ create_list(:note_on_issue, 2, noteable: issue_a, project: project)
+ create(:note_on_issue, noteable: issue_b, project: project)
+ end
+
+ include_examples 'N+1 query check'
+ 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 1cc30b95162..98a3f08baa6 100644
--- a/spec/requests/api/graphql/project/jira_import_spec.rb
+++ b/spec/requests/api/graphql/project/jira_import_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe 'query Jira import data' do
total_issue_count: 4
)
end
+
let_it_be(:jira_import2) do
create(
:jira_import_state, :finished,
@@ -31,6 +32,7 @@ RSpec.describe 'query Jira import data' do
total_issue_count: 3
)
end
+
let(:query) do
%(
query {
diff --git a/spec/requests/api/graphql/project/merge_requests_spec.rb b/spec/requests/api/graphql/project/merge_requests_spec.rb
index c737e0b8caf..2b8d537f9fc 100644
--- a/spec/requests/api/graphql/project/merge_requests_spec.rb
+++ b/spec/requests/api/graphql/project/merge_requests_spec.rb
@@ -243,6 +243,17 @@ RSpec.describe 'getting merge request listings nested in a project' do
include_examples 'N+1 query check'
end
+
+ context 'when requesting `user_discussions_count`' do
+ let(:requested_fields) { [:user_discussions_count] }
+
+ before do
+ create_list(:note_on_merge_request, 2, noteable: merge_request_a, project: project)
+ create(:note_on_merge_request, noteable: merge_request_c, project: project)
+ end
+
+ include_examples 'N+1 query check'
+ end
end
describe 'sorting and pagination' do
diff --git a/spec/requests/api/graphql/project/project_statistics_spec.rb b/spec/requests/api/graphql/project/project_statistics_spec.rb
index c226b10ab51..b57c594c64f 100644
--- a/spec/requests/api/graphql/project/project_statistics_spec.rb
+++ b/spec/requests/api/graphql/project/project_statistics_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe 'rendering project statistics' do
include GraphqlHelpers
let(:project) { create(:project) }
- let!(:project_statistics) { create(:project_statistics, project: project, packages_size: 5.gigabytes) }
+ let!(:project_statistics) { create(:project_statistics, project: project, packages_size: 5.gigabytes, uploads_size: 3.gigabytes) }
let(:user) { create(:user) }
let(:query) do
@@ -31,6 +31,12 @@ RSpec.describe 'rendering project statistics' do
expect(graphql_data['project']['statistics']['packagesSize']).to eq(5.gigabytes)
end
+ it 'includes uploads size if the user can read the statistics' do
+ post_graphql(query, current_user: user)
+
+ expect(graphql_data_at(:project, :statistics, :uploadsSize)).to eq(3.gigabytes)
+ end
+
context 'when the project is public' do
let(:project) { create(:project, :public) }
diff --git a/spec/requests/api/graphql/project/release_spec.rb b/spec/requests/api/graphql/project/release_spec.rb
index 8fce29d0dc6..57dbe258ce4 100644
--- a/spec/requests/api/graphql/project/release_spec.rb
+++ b/spec/requests/api/graphql/project/release_spec.rb
@@ -13,7 +13,11 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let_it_be(:link_filepath) { '/direct/asset/link/path' }
let_it_be(:released_at) { Time.now - 1.day }
- let(:params_for_issues_and_mrs) { { scope: 'all', state: 'opened', release_tag: release.tag } }
+ let(:base_url_params) { { scope: 'all', release_tag: release.tag } }
+ let(:opened_url_params) { { state: 'opened', **base_url_params } }
+ let(:merged_url_params) { { state: 'merged', **base_url_params } }
+ let(:closed_url_params) { { state: 'closed', **base_url_params } }
+
let(:post_query) { post_graphql(query, current_user: current_user) }
let(:path_prefix) { %w[project release] }
let(:data) { graphql_data.dig(*path) }
@@ -143,7 +147,7 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
'name' => link.name,
'url' => link.url,
'external' => link.external?,
- 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << link.filepath : link.url
+ 'directAssetUrl' => link.filepath ? Gitlab::Routing.url_helpers.project_release_url(project, release) << "/downloads#{link.filepath}" : link.url
}
end
@@ -180,8 +184,11 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
let(:release_fields) do
query_graphql_field(:links, nil, %{
selfUrl
- mergeRequestsUrl
- issuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ openedIssuesUrl
+ closedIssuesUrl
})
end
@@ -190,8 +197,11 @@ RSpec.describe 'Query.project(fullPath).release(tagName)' do
expect(data).to eq(
'selfUrl' => project_release_url(project, release),
- 'mergeRequestsUrl' => project_merge_requests_url(project, params_for_issues_and_mrs),
- 'issuesUrl' => project_issues_url(project, params_for_issues_and_mrs)
+ 'openedMergeRequestsUrl' => project_merge_requests_url(project, opened_url_params),
+ 'mergedMergeRequestsUrl' => project_merge_requests_url(project, merged_url_params),
+ 'closedMergeRequestsUrl' => project_merge_requests_url(project, closed_url_params),
+ 'openedIssuesUrl' => project_issues_url(project, opened_url_params),
+ 'closedIssuesUrl' => project_issues_url(project, closed_url_params)
)
end
end
diff --git a/spec/requests/api/graphql/project/releases_spec.rb b/spec/requests/api/graphql/project/releases_spec.rb
index 7c57c0e9177..6e364c7d7b5 100644
--- a/spec/requests/api/graphql/project/releases_spec.rb
+++ b/spec/requests/api/graphql/project/releases_spec.rb
@@ -10,6 +10,11 @@ RSpec.describe 'Query.project(fullPath).releases()' do
let_it_be(:reporter) { create(:user) }
let_it_be(:developer) { create(:user) }
+ let(:base_url_params) { { scope: 'all', release_tag: release.tag } }
+ let(:opened_url_params) { { state: 'opened', **base_url_params } }
+ let(:merged_url_params) { { state: 'merged', **base_url_params } }
+ let(:closed_url_params) { { state: 'closed', **base_url_params } }
+
let(:query) do
graphql_query_for(:project, { fullPath: project.full_path },
%{
@@ -37,8 +42,11 @@ RSpec.describe 'Query.project(fullPath).releases()' do
}
links {
selfUrl
- mergeRequestsUrl
- issuesUrl
+ openedMergeRequestsUrl
+ mergedMergeRequestsUrl
+ closedMergeRequestsUrl
+ openedIssuesUrl
+ closedIssuesUrl
}
}
}
@@ -101,8 +109,11 @@ RSpec.describe 'Query.project(fullPath).releases()' do
},
'links' => {
'selfUrl' => project_release_url(project, release),
- 'mergeRequestsUrl' => project_merge_requests_url(project, params_for_issues_and_mrs),
- 'issuesUrl' => project_issues_url(project, params_for_issues_and_mrs)
+ 'openedMergeRequestsUrl' => project_merge_requests_url(project, opened_url_params),
+ 'mergedMergeRequestsUrl' => project_merge_requests_url(project, merged_url_params),
+ 'closedMergeRequestsUrl' => project_merge_requests_url(project, closed_url_params),
+ 'openedIssuesUrl' => project_issues_url(project, opened_url_params),
+ 'closedIssuesUrl' => project_issues_url(project, closed_url_params)
}
)
end
@@ -300,4 +311,77 @@ RSpec.describe 'Query.project(fullPath).releases()' do
it_behaves_like 'no access to any release data'
end
end
+
+ describe 'sorting behavior' do
+ let_it_be(:today) { Time.now }
+ let_it_be(:yesterday) { today - 1.day }
+ let_it_be(:tomorrow) { today + 1.day }
+
+ let_it_be(:project) { create(:project, :repository, :public) }
+
+ let_it_be(:release_v1) { create(:release, project: project, tag: 'v1', released_at: yesterday, created_at: tomorrow) }
+ let_it_be(:release_v2) { create(:release, project: project, tag: 'v2', released_at: today, created_at: yesterday) }
+ let_it_be(:release_v3) { create(:release, project: project, tag: 'v3', released_at: tomorrow, created_at: today) }
+
+ let(:current_user) { developer }
+
+ let(:params) { nil }
+
+ let(:sorted_tags) do
+ graphql_data.dig('project', 'releases', 'nodes').map { |release| release['tagName'] }
+ end
+
+ let(:query) do
+ graphql_query_for(:project, { fullPath: project.full_path },
+ %{
+ releases#{params ? "(#{params})" : ""} {
+ nodes {
+ tagName
+ }
+ }
+ })
+ end
+
+ before do
+ post_query
+ end
+
+ context 'when no sort: parameter is provided' do
+ it 'returns the results with the default sort applied (sort: RELEASED_AT_DESC)' do
+ expect(sorted_tags).to eq(%w(v3 v2 v1))
+ end
+ end
+
+ context 'with sort: RELEASED_AT_DESC' do
+ let(:params) { 'sort: RELEASED_AT_DESC' }
+
+ it 'returns the releases ordered by released_at in descending order' do
+ expect(sorted_tags).to eq(%w(v3 v2 v1))
+ end
+ end
+
+ context 'with sort: RELEASED_AT_ASC' do
+ let(:params) { 'sort: RELEASED_AT_ASC' }
+
+ it 'returns the releases ordered by released_at in ascending order' do
+ expect(sorted_tags).to eq(%w(v1 v2 v3))
+ end
+ end
+
+ context 'with sort: CREATED_DESC' do
+ let(:params) { 'sort: CREATED_DESC' }
+
+ it 'returns the releases ordered by created_at in descending order' do
+ expect(sorted_tags).to eq(%w(v1 v3 v2))
+ end
+ end
+
+ context 'with sort: CREATED_ASC' do
+ let(:params) { 'sort: CREATED_ASC' }
+
+ it 'returns the releases ordered by created_at in ascending order' do
+ expect(sorted_tags).to eq(%w(v2 v3 v1))
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/terraform/states_spec.rb b/spec/requests/api/graphql/project/terraform/states_spec.rb
new file mode 100644
index 00000000000..8b67b549efa
--- /dev/null
+++ b/spec/requests/api/graphql/project/terraform/states_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'query terraform states' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:terraform_state) { create(:terraform_state, :with_version, :locked, project: project) }
+ let_it_be(:latest_version) { terraform_state.latest_version }
+
+ let(:query) do
+ graphql_query_for(:project, { fullPath: project.full_path },
+ %{
+ terraformStates {
+ count
+ nodes {
+ id
+ name
+ lockedAt
+ createdAt
+ updatedAt
+
+ latestVersion {
+ id
+ createdAt
+ updatedAt
+
+ createdByUser {
+ id
+ }
+
+ job {
+ name
+ }
+ }
+
+ lockedByUser {
+ id
+ }
+ }
+ }
+ })
+ end
+
+ let(:current_user) { project.creator }
+ let(:data) { graphql_data.dig('project', 'terraformStates') }
+
+ before do
+ post_graphql(query, current_user: current_user)
+ end
+
+ it 'returns terraform state data', :aggregate_failures do
+ state = data.dig('nodes', 0)
+ version = state['latestVersion']
+
+ expect(state['id']).to eq(terraform_state.to_global_id.to_s)
+ expect(state['name']).to eq(terraform_state.name)
+ expect(state['lockedAt']).to eq(terraform_state.locked_at.iso8601)
+ expect(state['createdAt']).to eq(terraform_state.created_at.iso8601)
+ expect(state['updatedAt']).to eq(terraform_state.updated_at.iso8601)
+ expect(state.dig('lockedByUser', 'id')).to eq(terraform_state.locked_by_user.to_global_id.to_s)
+
+ expect(version['id']).to eq(latest_version.to_global_id.to_s)
+ expect(version['createdAt']).to eq(latest_version.created_at.iso8601)
+ expect(version['updatedAt']).to eq(latest_version.updated_at.iso8601)
+ expect(version.dig('createdByUser', 'id')).to eq(latest_version.created_by_user.to_global_id.to_s)
+ expect(version.dig('job', 'name')).to eq(latest_version.build.name)
+ end
+
+ it 'returns count of terraform states' do
+ count = data.dig('count')
+ expect(count).to be(project.terraform_states.size)
+ end
+
+ context 'unauthorized users' do
+ let(:current_user) { nil }
+
+ it { expect(data).to be_nil }
+ end
+end
diff --git a/spec/requests/api/graphql/read_only_spec.rb b/spec/requests/api/graphql/read_only_spec.rb
index ce8a3f6ef5c..d2a45603886 100644
--- a/spec/requests/api/graphql/read_only_spec.rb
+++ b/spec/requests/api/graphql/read_only_spec.rb
@@ -3,55 +3,11 @@
require 'spec_helper'
RSpec.describe 'Requests on a read-only node' do
- include GraphqlHelpers
-
- before do
- allow(Gitlab::Database).to receive(:read_only?) { true }
- end
-
- context 'mutations' do
- let(:current_user) { note.author }
- let!(:note) { create(:note) }
-
- let(:mutation) do
- variables = {
- id: GitlabSchema.id_from_object(note).to_s
- }
-
- graphql_mutation(:destroy_note, variables)
- end
-
- def mutation_response
- graphql_mutation_response(:destroy_note)
- end
-
- it 'disallows the query' do
- post_graphql_mutation(mutation, current_user: current_user)
-
- expect(json_response['errors'].first['message']).to eq(Mutations::BaseMutation::ERROR_MESSAGE)
- end
-
- it 'does not destroy the Note' do
- expect do
- post_graphql_mutation(mutation, current_user: current_user)
- end.not_to change { Note.count }
- end
- end
-
- context 'read-only queries' do
- let(:current_user) { create(:user) }
- let(:project) { create(:project, :repository) }
-
+ context 'when db is read-only' do
before do
- project.add_developer(current_user)
+ allow(Gitlab::Database).to receive(:read_only?) { true }
end
- it 'allows the query' do
- query = graphql_query_for('project', 'fullPath' => project.full_path)
-
- post_graphql(query, current_user: current_user)
-
- expect(graphql_data['project']).not_to be_nil
- end
+ it_behaves_like 'graphql on a read-only GitLab instance'
end
end
diff --git a/spec/requests/api/graphql/terraform/state/delete_spec.rb b/spec/requests/api/graphql/terraform/state/delete_spec.rb
new file mode 100644
index 00000000000..35927d03b49
--- /dev/null
+++ b/spec/requests/api/graphql/terraform/state/delete_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'delete a terraform state' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:state) { create(:terraform_state, project: project) }
+ let(:mutation) { graphql_mutation(:terraform_state_delete, id: state.to_global_id.to_s) }
+
+ before do
+ post_graphql_mutation(mutation, current_user: user)
+ end
+
+ include_examples 'a working graphql query'
+
+ it 'deletes the state' do
+ expect { state.reload }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+end
diff --git a/spec/requests/api/graphql/terraform/state/lock_spec.rb b/spec/requests/api/graphql/terraform/state/lock_spec.rb
new file mode 100644
index 00000000000..e4d3b6336ab
--- /dev/null
+++ b/spec/requests/api/graphql/terraform/state/lock_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'lock a terraform state' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:state) { create(:terraform_state, project: project) }
+ let(:mutation) { graphql_mutation(:terraform_state_lock, id: state.to_global_id.to_s) }
+
+ before do
+ expect(state).not_to be_locked
+ post_graphql_mutation(mutation, current_user: user)
+ end
+
+ include_examples 'a working graphql query'
+
+ it 'locks the state' do
+ expect(state.reload).to be_locked
+ expect(state.locked_by_user).to eq(user)
+ end
+end
diff --git a/spec/requests/api/graphql/terraform/state/unlock_spec.rb b/spec/requests/api/graphql/terraform/state/unlock_spec.rb
new file mode 100644
index 00000000000..e90730f2d8f
--- /dev/null
+++ b/spec/requests/api/graphql/terraform/state/unlock_spec.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'unlock a terraform state' do
+ include GraphqlHelpers
+
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, maintainer_projects: [project]) }
+
+ let(:state) { create(:terraform_state, :locked, project: project) }
+ let(:mutation) { graphql_mutation(:terraform_state_unlock, id: state.to_global_id.to_s) }
+
+ before do
+ expect(state).to be_locked
+ post_graphql_mutation(mutation, current_user: user)
+ end
+
+ include_examples 'a working graphql query'
+
+ it 'unlocks the state' do
+ expect(state.reload).not_to be_locked
+ end
+end
diff --git a/spec/requests/api/graphql/user/group_member_query_spec.rb b/spec/requests/api/graphql/user/group_member_query_spec.rb
index 3a16d962214..e47cef8cc37 100644
--- a/spec/requests/api/graphql/user/group_member_query_spec.rb
+++ b/spec/requests/api/graphql/user/group_member_query_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe 'GroupMember' do
}
HEREDOC
end
+
let_it_be(:query) do
graphql_query_for('user', { id: member.user.to_global_id.to_s }, query_graphql_field("groupMemberships", {}, fields))
end
diff --git a/spec/requests/api/graphql/user/project_member_query_spec.rb b/spec/requests/api/graphql/user/project_member_query_spec.rb
index 0790e148caf..01827e94d5d 100644
--- a/spec/requests/api/graphql/user/project_member_query_spec.rb
+++ b/spec/requests/api/graphql/user/project_member_query_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe 'ProjectMember' do
}
HEREDOC
end
+
let_it_be(:query) do
graphql_query_for('user', { id: member.user.to_global_id.to_s }, query_graphql_field("projectMemberships", {}, fields))
end
diff --git a/spec/requests/api/graphql/user_query_spec.rb b/spec/requests/api/graphql/user_query_spec.rb
index 79debd0b7ef..738e120549e 100644
--- a/spec/requests/api/graphql/user_query_spec.rb
+++ b/spec/requests/api/graphql/user_query_spec.rb
@@ -32,22 +32,27 @@ RSpec.describe 'getting user information' do
create(:merge_request, :unique_branches, :unique_author,
source_project: project_a, assignees: [user])
end
+
let_it_be(:assigned_mr_b) do
create(:merge_request, :unique_branches, :unique_author,
source_project: project_b, assignees: [user])
end
+
let_it_be(:assigned_mr_c) do
create(:merge_request, :unique_branches, :unique_author,
source_project: project_b, assignees: [user])
end
+
let_it_be(:authored_mr) do
create(:merge_request, :unique_branches,
source_project: project_a, author: user)
end
+
let_it_be(:authored_mr_b) do
create(:merge_request, :unique_branches,
source_project: project_b, author: user)
end
+
let_it_be(:authored_mr_c) do
create(:merge_request, :unique_branches,
source_project: project_b, author: user)
@@ -59,6 +64,7 @@ RSpec.describe 'getting user information' do
let(:user_params) { { username: user.username } }
before do
+ create(:user_status, user: user)
post_graphql(query, current_user: current_user)
end
@@ -76,9 +82,15 @@ RSpec.describe 'getting user information' do
'username' => presenter.username,
'webUrl' => presenter.web_url,
'avatarUrl' => presenter.avatar_url,
- 'status' => presenter.status,
'email' => presenter.email
))
+
+ expect(graphql_data['user']['status']).to match(
+ a_hash_including(
+ 'emoji' => presenter.status.emoji,
+ 'message' => presenter.status.message,
+ 'availability' => presenter.status.availability.upcase
+ ))
end
describe 'assignedMergeRequests' do
diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb
index 94a66f54e4d..5dc8edb87e9 100644
--- a/spec/requests/api/graphql_spec.rb
+++ b/spec/requests/api/graphql_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe 'GraphQL' do
include GraphqlHelpers
- let(:query) { graphql_query_for('echo', 'text' => 'Hello world' ) }
+ let(:query) { graphql_query_for('echo', text: 'Hello world' ) }
context 'logging' do
shared_examples 'logging a graphql query' do
diff --git a/spec/requests/api/group_labels_spec.rb b/spec/requests/api/group_labels_spec.rb
index f965a845bbe..72621e2ce5e 100644
--- a/spec/requests/api/group_labels_spec.rb
+++ b/spec/requests/api/group_labels_spec.rb
@@ -7,60 +7,97 @@ RSpec.describe API::GroupLabels do
let(:group) { create(:group) }
let(:subgroup) { create(:group, parent: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
- let!(:group_label1) { create(:group_label, title: 'feature', group: group) }
+ let!(:group_label1) { create(:group_label, title: 'feature-label', group: group) }
let!(:group_label2) { create(:group_label, title: 'bug', group: group) }
- let!(:subgroup_label) { create(:group_label, title: 'support', group: subgroup) }
+ let!(:subgroup_label) { create(:group_label, title: 'support-label', group: subgroup) }
describe 'GET :id/labels' do
- it 'returns all available labels for the group' do
- get api("/groups/#{group.id}/labels", user)
+ context 'get current group labels' do
+ let(:request) { get api("/groups/#{group.id}/labels", user) }
+ let(:expected_labels) { [group_label1.name, group_label2.name] }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to all(match_schema('public_api/v4/labels/label'))
- expect(json_response.size).to eq(2)
- expect(json_response.map {|r| r['name'] }).to contain_exactly('feature', 'bug')
- end
-
- context 'when the with_counts parameter is set' do
- it 'includes counts in the response' do
- get api("/groups/#{group.id}/labels", user), params: { with_counts: 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).to all(match_schema('public_api/v4/labels/label_with_counts'))
- expect(json_response.size).to eq(2)
- expect(json_response.map { |r| r['open_issues_count'] }).to contain_exactly(0, 0)
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/groups/#{group.id}/labels?search=lab", user) }
+ let(:expected_labels) { [group_label1.name] }
+
+ it_behaves_like 'fetches labels'
end
- end
- end
- describe 'GET :subgroup_id/labels' do
- context 'when the include_ancestor_groups parameter is not set' do
- it 'returns all available labels for the group and ancestor groups' do
- get api("/groups/#{subgroup.id}/labels", user)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to all(match_schema('public_api/v4/labels/label'))
- expect(json_response.size).to eq(3)
- expect(json_response.map {|r| r['name'] }).to contain_exactly('feature', 'bug', 'support')
+ context 'when the with_counts parameter is set' do
+ it 'includes counts in the response' do
+ get api("/groups/#{group.id}/labels", user), params: { with_counts: 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).to all(match_schema('public_api/v4/labels/label_with_counts'))
+ expect(json_response.size).to eq(2)
+ expect(json_response.map { |r| r['open_issues_count'] }).to contain_exactly(0, 0)
+ end
+ end
+
+ context 'when include_descendant_groups param is provided' do
+ let!(:project) { create(:project, group: group) }
+ let!(:project_label1) { create(:label, title: 'project-label1', project: project, priority: 3) }
+ let!(:project_label2) { create(:label, title: 'project-bug', project: project) }
+
+ let(:request) { get api("/groups/#{group.id}/labels", user), params: { include_descendant_groups: true } }
+ let(:expected_labels) { [group_label1.name, group_label2.name, subgroup_label.name] }
+
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/groups/#{group.id}/labels", user), params: { search: 'lab', include_descendant_groups: true } }
+ let(:expected_labels) { [group_label1.name, subgroup_label.name] }
+
+ it_behaves_like 'fetches labels'
+ end
+
+ context 'when only_group_labels param is false' do
+ let(:request) { get api("/groups/#{group.id}/labels", user), params: { include_descendant_groups: true, only_group_labels: false } }
+ let(:expected_labels) { [group_label1.name, group_label2.name, subgroup_label.name, project_label1.name, project_label2.name] }
+
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/groups/#{group.id}/labels", user), params: { search: 'lab', include_descendant_groups: true, only_group_labels: false } }
+ let(:expected_labels) { [group_label1.name, subgroup_label.name, project_label1.name] }
+
+ it_behaves_like 'fetches labels'
+ end
+ end
end
end
- context 'when the include_ancestor_groups parameter is set to false' do
- it 'returns all available labels for the group but not for ancestor groups' do
- get api("/groups/#{subgroup.id}/labels", user), params: { include_ancestor_groups: false }
+ describe 'with subgroup labels' do
+ context 'when the include_ancestor_groups parameter is not set' do
+ let(:request) { get api("/groups/#{subgroup.id}/labels", user) }
+ let(:expected_labels) { [group_label1.name, group_label2.name, subgroup_label.name] }
+
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/groups/#{subgroup.id}/labels?search=lab", user) }
+ let(:expected_labels) { [group_label1.name, subgroup_label.name] }
+
+ it_behaves_like 'fetches labels'
+ end
+ end
+
+ context 'when the include_ancestor_groups parameter is set to false' do
+ let(:request) { get api("/groups/#{subgroup.id}/labels", user), params: { include_ancestor_groups: false } }
+ let(:expected_labels) { [subgroup_label.name] }
+
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/groups/#{subgroup.id}/labels?search=lab", user), params: { include_ancestor_groups: false } }
+ let(:expected_labels) { [subgroup_label.name] }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to all(match_schema('public_api/v4/labels/label'))
- expect(json_response.size).to eq(1)
- expect(json_response.map {|r| r['name'] }).to contain_exactly('support')
+ it_behaves_like 'fetches labels'
+ end
end
end
end
@@ -223,7 +260,7 @@ RSpec.describe API::GroupLabels do
expect(response).to have_gitlab_http_status(:ok)
expect(subgroup.labels[0].name).to eq('New Label')
- expect(group_label1.name).to eq('feature')
+ expect(group_label1.name).to eq(group_label1.title)
end
it 'returns 404 if label does not exist' do
@@ -278,7 +315,7 @@ RSpec.describe API::GroupLabels do
expect(response).to have_gitlab_http_status(:ok)
expect(subgroup.labels[0].name).to eq('New Label')
- expect(group_label1.name).to eq('feature')
+ expect(group_label1.name).to eq(group_label1.title)
end
it 'returns 404 if label does not exist' do
diff --git a/spec/requests/api/import_github_spec.rb b/spec/requests/api/import_github_spec.rb
index bbfb17fe753..5bbcb0c1950 100644
--- a/spec/requests/api/import_github_spec.rb
+++ b/spec/requests/api/import_github_spec.rb
@@ -57,6 +57,22 @@ RSpec.describe API::ImportGithub do
expect(json_response['name']).to eq(project.name)
end
+ it 'returns 201 response when the project is imported successfully from GHE' do
+ allow(Gitlab::LegacyGithubImport::ProjectCreator)
+ .to receive(:new).with(provider_repo, provider_repo.name, user.namespace, user, access_params, type: provider)
+ .and_return(double(execute: project))
+
+ post api("/import/github", user), params: {
+ target_namespace: user.namespace_path,
+ personal_access_token: token,
+ repo_id: non_existing_record_id,
+ github_hostname: "https://github.somecompany.com/"
+ }
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response).to be_a Hash
+ expect(json_response['name']).to eq(project.name)
+ end
+
it 'returns 422 response when user can not create projects in the chosen namespace' do
other_namespace = create(:group, name: 'other_namespace')
diff --git a/spec/requests/api/internal/base_spec.rb b/spec/requests/api/internal/base_spec.rb
index ab5f09305ce..6fe77727702 100644
--- a/spec/requests/api/internal/base_spec.rb
+++ b/spec/requests/api/internal/base_spec.rb
@@ -253,6 +253,7 @@ RSpec.describe API::Internal::Base do
describe "POST /internal/lfs_authenticate" do
before do
+ stub_lfs_setting(enabled: true)
project.add_developer(user)
end
@@ -293,6 +294,33 @@ RSpec.describe API::Internal::Base do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ it 'returns a 404 when LFS is disabled on the project' do
+ project.update!(lfs_enabled: false)
+ lfs_auth_user(user.id, project)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'other repository types' do
+ it 'returns the correct information for a project wiki' do
+ wiki = create(:project_wiki, project: project)
+ lfs_auth_user(user.id, wiki)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['username']).to eq(user.username)
+ expect(json_response['repository_http_path']).to eq(wiki.http_url_to_repo)
+ expect(json_response['expires_in']).to eq(Gitlab::LfsToken::DEFAULT_EXPIRE_TIME)
+ expect(Gitlab::LfsToken.new(user).token_valid?(json_response['lfs_token'])).to be_truthy
+ end
+
+ it 'returns a 404 when the container does not support LFS' do
+ snippet = create(:project_snippet)
+ lfs_auth_user(user.id, snippet)
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
end
context 'deploy key' do
diff --git a/spec/requests/api/internal/pages_spec.rb b/spec/requests/api/internal/pages_spec.rb
index e58eba02132..9a63e2a8ed5 100644
--- a/spec/requests/api/internal/pages_spec.rb
+++ b/spec/requests/api/internal/pages_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe API::Internal::Pages do
before do
allow(Gitlab::Pages).to receive(:secret).and_return(pages_secret)
+ stub_pages_object_storage(::Pages::DeploymentUploader)
end
describe "GET /internal/pages/status" do
@@ -38,6 +39,12 @@ RSpec.describe API::Internal::Pages do
get api("/internal/pages"), headers: headers, params: { host: host }
end
+ around do |example|
+ freeze_time do
+ example.run
+ end
+ end
+
context 'not authenticated' do
it 'responds with 401 Unauthorized' do
query_host('pages.gitlab.io')
@@ -55,7 +62,9 @@ RSpec.describe API::Internal::Pages do
end
def deploy_pages(project)
+ deployment = create(:pages_deployment, project: project)
project.mark_pages_as_deployed
+ project.update_pages_deployment!(deployment)
end
context 'domain does not exist' do
@@ -182,6 +191,7 @@ RSpec.describe API::Internal::Pages do
expect(json_response['certificate']).to eq(pages_domain.certificate)
expect(json_response['key']).to eq(pages_domain.key)
+ deployment = project.pages_metadatum.pages_deployment
expect(json_response['lookup_paths']).to eq(
[
{
@@ -190,8 +200,12 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/',
'source' => {
- 'type' => 'file',
- 'path' => 'gitlab-org/gitlab-ce/public/'
+ 'type' => 'zip',
+ 'path' => deployment.file.url(expire_at: 1.day.from_now),
+ 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
+ 'sha256' => deployment.file_sha256,
+ 'file_size' => deployment.size,
+ 'file_count' => deployment.file_count
}
}
]
@@ -218,6 +232,7 @@ RSpec.describe API::Internal::Pages do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('internal/pages/virtual_domain')
+ deployment = project.pages_metadatum.pages_deployment
expect(json_response['lookup_paths']).to eq(
[
{
@@ -226,8 +241,12 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/myproject/',
'source' => {
- 'type' => 'file',
- 'path' => 'mygroup/myproject/public/'
+ 'type' => 'zip',
+ 'path' => deployment.file.url(expire_at: 1.day.from_now),
+ 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
+ 'sha256' => deployment.file_sha256,
+ 'file_size' => deployment.size,
+ 'file_count' => deployment.file_count
}
}
]
@@ -235,6 +254,20 @@ RSpec.describe API::Internal::Pages do
end
end
+ it 'avoids N+1 queries' do
+ project = create(:project, group: group)
+ deploy_pages(project)
+
+ control = ActiveRecord::QueryRecorder.new { query_host('mygroup.gitlab-pages.io') }
+
+ 3.times do
+ project = create(:project, group: group)
+ deploy_pages(project)
+ end
+
+ expect { query_host('mygroup.gitlab-pages.io') }.not_to exceed_query_limit(control)
+ end
+
context 'group root project' do
it 'responds with the correct domain configuration' do
project = create(:project, group: group, name: 'mygroup.gitlab-pages.io')
@@ -245,6 +278,7 @@ RSpec.describe API::Internal::Pages do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('internal/pages/virtual_domain')
+ deployment = project.pages_metadatum.pages_deployment
expect(json_response['lookup_paths']).to eq(
[
{
@@ -253,8 +287,12 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/',
'source' => {
- 'type' => 'file',
- 'path' => 'mygroup/mygroup.gitlab-pages.io/public/'
+ 'type' => 'zip',
+ 'path' => deployment.file.url(expire_at: 1.day.from_now),
+ 'global_id' => "gid://gitlab/PagesDeployment/#{deployment.id}",
+ 'sha256' => deployment.file_sha256,
+ 'file_size' => deployment.size,
+ 'file_count' => deployment.file_count
}
}
]
diff --git a/spec/requests/api/invitations_spec.rb b/spec/requests/api/invitations_spec.rb
new file mode 100644
index 00000000000..75586970abb
--- /dev/null
+++ b/spec/requests/api/invitations_spec.rb
@@ -0,0 +1,301 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::Invitations do
+ let(:maintainer) { create(:user, username: 'maintainer_user') }
+ let(:developer) { create(:user) }
+ let(:access_requester) { create(:user) }
+ let(:stranger) { create(:user) }
+ let(:email) { 'email1@example.com' }
+ let(:email2) { 'email2@example.com' }
+
+ let(:project) do
+ create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
+ project.add_developer(developer)
+ project.add_maintainer(maintainer)
+ project.request_access(access_requester)
+ end
+ end
+
+ let!(:group) do
+ create(:group, :public) do |group|
+ group.add_developer(developer)
+ group.add_owner(maintainer)
+ group.request_access(access_requester)
+ end
+ end
+
+ def invitations_url(source, user)
+ api("/#{source.model_name.plural}/#{source.id}/invitations", user)
+ end
+
+ shared_examples 'POST /:source_type/:id/invitations' do |source_type|
+ context "with :source_type == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) do
+ post invitations_url(source, stranger),
+ params: { email: email, access_level: Member::MAINTAINER }
+ end
+ end
+
+ context 'when authenticated as a non-member or member with insufficient rights' do
+ %i[access_requester stranger developer].each do |type|
+ context "as a #{type}" do
+ it 'returns 403' do
+ user = public_send(type)
+
+ post invitations_url(source, user), params: { email: email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+ end
+
+ context 'when authenticated as a maintainer/owner' do
+ context 'and new member is already a requester' do
+ it 'does not transform the requester into a proper member' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.not_to change { source.members.count }
+ end
+ end
+
+ it 'invites a new member' do
+ expect do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.requesters.count }.by(1)
+ end
+
+ it 'invites a list of new email addresses' do
+ expect do
+ email_list = [email, email2].join(',')
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email_list, access_level: Member::DEVELOPER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end.to change { source.requesters.count }.by(2)
+ end
+ end
+
+ context 'access levels' do
+ it 'does not create the member if group level is higher' do
+ parent = create(:group)
+
+ group.update!(parent: parent)
+ project.update!(group: group)
+ parent.add_developer(stranger)
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: stranger.email, access_level: Member::REPORTER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][stranger.email]).to eq("Access level should be greater than or equal to Developer inherited membership from group #{parent.name}")
+ end
+
+ it 'creates the member if group level is lower' do
+ parent = create(:group)
+
+ group.update!(parent: parent)
+ project.update!(group: group)
+ parent.add_developer(stranger)
+
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: stranger.email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+
+ context 'access expiry date' do
+ subject do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email, access_level: Member::DEVELOPER, expires_at: expires_at }
+ end
+
+ context 'when set to a date in the past' do
+ let(:expires_at) { 2.days.ago.to_date }
+
+ it 'does not create a member' do
+ expect do
+ subject
+ end.not_to change { source.members.count }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][email]).to eq('Expires at cannot be a date in the past')
+ end
+ end
+
+ context 'when set to a date in the future' do
+ let(:expires_at) { 2.days.from_now.to_date }
+
+ it 'invites a member' do
+ expect do
+ subject
+ end.to change { source.requesters.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:created)
+ end
+ end
+ end
+
+ it "returns a message if member already exists" do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: maintainer.email, access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message'][maintainer.email]).to eq("Already a member of #{source.name}")
+ end
+
+ it 'returns 404 when the email is not valid' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: '', access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['message']).to eq('Email cannot be blank')
+ end
+
+ it 'returns 404 when the email list is not a valid format' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: 'email1@example.com,not-an-email', access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('email contains an invalid email address')
+ end
+
+ it 'returns 400 when email is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { access_level: Member::MAINTAINER }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 when access_level is not given' do
+ post api("/#{source_type.pluralize}/#{source.id}/invitations", maintainer),
+ params: { email: email }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'returns 400 when access_level is not valid' do
+ post invitations_url(source, maintainer),
+ params: { email: email, access_level: non_existing_record_access_level }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ describe 'POST /projects/:id/invitations' do
+ it_behaves_like 'POST /:source_type/:id/invitations', 'project' do
+ let(:source) { project }
+ end
+ end
+
+ describe 'POST /groups/:id/invitations' do
+ it_behaves_like 'POST /:source_type/:id/invitations', 'group' do
+ let(:source) { group }
+ end
+ end
+
+ shared_examples 'GET /:source_type/:id/invitations' do |source_type|
+ context "with :source_type == #{source_type.pluralize}" do
+ it_behaves_like 'a 404 response when source is private' do
+ let(:route) { get invitations_url(source, stranger) }
+ end
+
+ %i[maintainer developer access_requester stranger].each do |type|
+ context "when authenticated as a #{type}" do
+ it 'returns 200' do
+ user = public_send(type)
+
+ get invitations_url(source, user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(0)
+ end
+ end
+ end
+
+ it 'avoids N+1 queries' do
+ # Establish baseline
+ get invitations_url(source, maintainer)
+
+ control = ActiveRecord::QueryRecorder.new do
+ get invitations_url(source, maintainer)
+ end
+
+ invite_member_by_email(source, source_type, email, maintainer)
+
+ expect do
+ get invitations_url(source, maintainer)
+ end.not_to exceed_query_limit(control)
+ end
+
+ it 'does not find confirmed members' do
+ get invitations_url(source, developer)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.size).to eq(0)
+ expect(json_response.map { |u| u['id'] }).not_to match_array [maintainer.id, developer.id]
+ end
+
+ it 'finds all members with no query string specified' do
+ invite_member_by_email(source, source_type, email, developer)
+ invite_member_by_email(source, source_type, email2, developer)
+
+ get invitations_url(source, developer), params: { query: '' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq(2)
+ expect(json_response.map { |u| u['invite_email'] }).to match_array [email, email2]
+ end
+
+ it 'finds the invitation by invite_email with query string' do
+ invite_member_by_email(source, source_type, email, developer)
+ invite_member_by_email(source, source_type, email2, developer)
+
+ get invitations_url(source, developer), params: { query: email }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['invite_email']).to eq(email)
+ expect(json_response.first['created_by_name']).to eq(developer.name)
+ expect(json_response.first['user_name']).to eq(nil)
+ end
+
+ def invite_member_by_email(source, source_type, email, created_by)
+ create(:"#{source_type}_member", invite_token: '123', invite_email: email, source: source, user: nil, created_by: created_by)
+ end
+ end
+ end
+
+ describe 'GET /projects/:id/invitations' do
+ it_behaves_like 'GET /:source_type/:id/invitations', 'project' do
+ let(:source) { project }
+ end
+ end
+
+ describe 'GET /groups/:id/invitations' do
+ it_behaves_like 'GET /:source_type/:id/invitations', 'group' do
+ let(:source) { group }
+ end
+ end
+end
diff --git a/spec/requests/api/issues/get_project_issues_spec.rb b/spec/requests/api/issues/get_project_issues_spec.rb
index 4228ca2d5fd..da0bae8d5e7 100644
--- a/spec/requests/api/issues/get_project_issues_spec.rb
+++ b/spec/requests/api/issues/get_project_issues_spec.rb
@@ -54,11 +54,13 @@ RSpec.describe API::Issues do
let_it_be(:label) do
create(:label, title: 'label', color: '#FFAABB', project: project)
end
+
let!(:label_link) { create(:label_link, label: label, target: issue) }
let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
let_it_be(:empty_milestone) do
create(:milestone, title: '2.0.0', project: project)
end
+
let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
let(:no_milestone_title) { 'None' }
diff --git a/spec/requests/api/issues/issues_spec.rb b/spec/requests/api/issues/issues_spec.rb
index b8cbddd9ed4..0fe68be027c 100644
--- a/spec/requests/api/issues/issues_spec.rb
+++ b/spec/requests/api/issues/issues_spec.rb
@@ -54,11 +54,13 @@ RSpec.describe API::Issues do
let_it_be(:label) do
create(:label, title: 'label', color: '#FFAABB', project: project)
end
+
let!(:label_link) { create(:label_link, label: label, target: issue) }
let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
let_it_be(:empty_milestone) do
create(:milestone, title: '2.0.0', project: project)
end
+
let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
let(:no_milestone_title) { 'None' }
diff --git a/spec/requests/api/issues/post_projects_issues_spec.rb b/spec/requests/api/issues/post_projects_issues_spec.rb
index a7fe4d4509a..5b3e2363669 100644
--- a/spec/requests/api/issues/post_projects_issues_spec.rb
+++ b/spec/requests/api/issues/post_projects_issues_spec.rb
@@ -53,11 +53,13 @@ RSpec.describe API::Issues do
let_it_be(:label) do
create(:label, title: 'label', color: '#FFAABB', project: project)
end
+
let!(:label_link) { create(:label_link, label: label, target: issue) }
let(:milestone) { create(:milestone, title: '1.0.0', project: project) }
let_it_be(:empty_milestone) do
create(:milestone, title: '2.0.0', project: project)
end
+
let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) }
let(:no_milestone_title) { 'None' }
diff --git a/spec/requests/api/labels_spec.rb b/spec/requests/api/labels_spec.rb
index fc674fca9b2..b368f6e329c 100644
--- a/spec/requests/api/labels_spec.rb
+++ b/spec/requests/api/labels_spec.rb
@@ -178,8 +178,8 @@ RSpec.describe API::Labels do
end
describe 'GET /projects/:id/labels' do
- let(:group) { create(:group) }
- let!(:group_label) { create(:group_label, title: 'feature', group: group) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:group_label) { create(:group_label, title: 'feature label', group: group) }
before do
project.update!(group: group)
@@ -250,49 +250,41 @@ RSpec.describe API::Labels do
end
end
- context 'when the include_ancestor_groups parameter is not set' do
- let(:group) { create(:group) }
- let!(:group_label) { create(:group_label, title: 'feature', group: group) }
- let(:subgroup) { create(:group, parent: group) }
- let!(:subgroup_label) { create(:group_label, title: 'support', group: subgroup) }
+ context 'with subgroups' do
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:subgroup_label) { create(:group_label, title: 'support label', group: subgroup) }
before do
subgroup.add_owner(user)
project.update!(group: subgroup)
end
- it 'returns all available labels for the project, parent group and ancestor groups' do
- get api("/projects/#{project.id}/labels", user)
+ context 'when the include_ancestor_groups parameter is not set' do
+ let(:request) { get api("/projects/#{project.id}/labels", user) }
+ let(:expected_labels) { [priority_label.name, group_label.name, subgroup_label.name, label1.name] }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to all(match_schema('public_api/v4/labels/label'))
- expect(json_response.size).to eq(4)
- expect(json_response.map {|r| r['name'] }).to contain_exactly(group_label.name, subgroup_label.name, priority_label.name, label1.name)
- end
- end
+ it_behaves_like 'fetches labels'
- context 'when the include_ancestor_groups parameter is set to false' do
- let(:group) { create(:group) }
- let!(:group_label) { create(:group_label, title: 'feature', group: group) }
- let(:subgroup) { create(:group, parent: group) }
- let!(:subgroup_label) { create(:group_label, title: 'support', group: subgroup) }
+ context 'when search param is provided' do
+ let(:request) { get api("/projects/#{project.id}/labels?search=lab", user) }
+ let(:expected_labels) { [group_label.name, subgroup_label.name, label1.name] }
- before do
- subgroup.add_owner(user)
- project.update!(group: subgroup)
+ it_behaves_like 'fetches labels'
+ end
end
- it 'returns all available labels for the project and the parent group only' do
- get api("/projects/#{project.id}/labels", user), params: { include_ancestor_groups: false }
+ context 'when the include_ancestor_groups parameter is set to false' do
+ let(:request) { get api("/projects/#{project.id}/labels", user), params: { include_ancestor_groups: false } }
+ let(:expected_labels) { [subgroup_label.name, priority_label.name, label1.name] }
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to include_pagination_headers
- expect(json_response).to be_an Array
- expect(json_response).to all(match_schema('public_api/v4/labels/label'))
- expect(json_response.size).to eq(3)
- expect(json_response.map {|r| r['name'] }).to contain_exactly(subgroup_label.name, priority_label.name, label1.name)
+ it_behaves_like 'fetches labels'
+
+ context 'when search param is provided' do
+ let(:request) { get api("/projects/#{project.id}/labels?search=lab", user), params: { include_ancestor_groups: false } }
+ let(:expected_labels) { [subgroup_label.name, label1.name] }
+
+ it_behaves_like 'fetches labels'
+ end
end
end
end
@@ -513,7 +505,7 @@ RSpec.describe API::Labels do
end
describe 'PUT /projects/:id/labels/promote' do
- let(:group) { create(:group) }
+ let_it_be(:group) { create(:group) }
before do
group.add_owner(user)
diff --git a/spec/requests/api/lint_spec.rb b/spec/requests/api/lint_spec.rb
index 9890cdc20c0..aecbcfb5b5a 100644
--- a/spec/requests/api/lint_spec.rb
+++ b/spec/requests/api/lint_spec.rb
@@ -9,12 +9,13 @@ RSpec.describe API::Lint do
File.read(Rails.root.join('spec/support/gitlab_stubs/gitlab_ci.yml'))
end
- it 'passes validation' do
+ it 'passes validation without warnings or errors' do
post api('/ci/lint'), params: { content: yaml_content }
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Hash
expect(json_response['status']).to eq('valid')
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq([])
end
@@ -26,6 +27,20 @@ RSpec.describe API::Lint do
end
end
+ context 'with valid .gitlab-ci.yaml with warnings' do
+ let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml }
+
+ it 'passes validation but returns warnings' do
+ post api('/ci/lint'), params: { content: yaml_content }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['status']).to eq('valid')
+ expect(json_response['warnings']).not_to be_empty
+ expect(json_response['status']).to eq('valid')
+ expect(json_response['errors']).to eq([])
+ end
+ end
+
context 'with an invalid .gitlab_ci.yml' do
context 'with invalid syntax' do
let(:yaml_content) { 'invalid content' }
@@ -35,6 +50,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['status']).to eq('invalid')
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['Invalid configuration format'])
end
@@ -54,6 +70,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['status']).to eq('invalid')
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
end
@@ -82,7 +99,18 @@ RSpec.describe API::Lint do
let(:project) { create(:project, :repository) }
let(:dry_run) { nil }
- RSpec.shared_examples 'valid config' do
+ RSpec.shared_examples 'valid config with warnings' do
+ it 'passes validation with warnings' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['valid']).to eq(true)
+ expect(json_response['errors']).to eq([])
+ expect(json_response['warnings']).not_to be_empty
+ end
+ end
+
+ RSpec.shared_examples 'valid config without warnings' do
it 'passes validation' do
ci_lint
@@ -94,6 +122,7 @@ RSpec.describe API::Lint do
expect(json_response).to be_an Hash
expect(json_response['merged_yaml']).to eq(expected_yaml)
expect(json_response['valid']).to eq(true)
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq([])
end
end
@@ -105,6 +134,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['merged_yaml']).to eq(yaml_content)
expect(json_response['valid']).to eq(false)
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
end
end
@@ -157,6 +187,7 @@ RSpec.describe API::Lint do
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['merged_yaml']).to eq(nil)
expect(json_response['valid']).to eq(false)
+ expect(json_response['warnings']).to eq([])
expect(json_response['errors']).to eq(['Insufficient permissions to create a new pipeline'])
end
end
@@ -186,7 +217,7 @@ RSpec.describe API::Lint do
)
end
- it_behaves_like 'valid config'
+ it_behaves_like 'valid config without warnings'
end
end
end
@@ -242,13 +273,19 @@ RSpec.describe API::Lint do
context 'when running as dry run' do
let(:dry_run) { true }
- it_behaves_like 'valid config'
+ it_behaves_like 'valid config without warnings'
end
context 'when running static validation' do
let(:dry_run) { false }
- it_behaves_like 'valid config'
+ it_behaves_like 'valid config without warnings'
+ end
+
+ context 'With warnings' do
+ let(:yaml_content) { { job: { script: 'ls', rules: [{ when: 'always' }] } }.to_yaml }
+
+ it_behaves_like 'valid config with warnings'
end
end
@@ -275,4 +312,167 @@ RSpec.describe API::Lint do
end
end
end
+
+ describe 'POST /projects/:id/ci/lint' do
+ subject(:ci_lint) { post api("/projects/#{project.id}/ci/lint", api_user), params: { dry_run: dry_run, content: yaml_content } }
+
+ let(:project) { create(:project, :repository) }
+ let(:dry_run) { nil }
+
+ let_it_be(:api_user) { create(:user) }
+
+ let_it_be(:yaml_content) do
+ { include: { local: 'another-gitlab-ci.yml' }, test: { stage: 'test', script: 'echo 1' } }.to_yaml
+ end
+
+ let_it_be(:included_content) do
+ { another_test: { stage: 'test', script: 'echo 1' } }.to_yaml
+ end
+
+ RSpec.shared_examples 'valid project config' do
+ it 'passes validation' do
+ ci_lint
+
+ included_config = YAML.safe_load(included_content, [Symbol])
+ root_config = YAML.safe_load(yaml_content, [Symbol])
+ expected_yaml = included_config.merge(root_config).except(:include).to_yaml
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response).to be_an Hash
+ expect(json_response['merged_yaml']).to eq(expected_yaml)
+ expect(json_response['valid']).to eq(true)
+ expect(json_response['errors']).to eq([])
+ end
+ end
+
+ RSpec.shared_examples 'invalid project config' do
+ it 'responds with errors about invalid configuration' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['merged_yaml']).to eq(yaml_content)
+ expect(json_response['valid']).to eq(false)
+ expect(json_response['errors']).to eq(['jobs config should contain at least one visible job'])
+ end
+ end
+
+ context 'when unauthenticated' do
+ let_it_be(:api_user) { nil }
+
+ it 'returns authentication error' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when authenticated as non-member' do
+ context 'when project is private' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it 'returns authentication error' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when project is public' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ end
+
+ context 'when running as dry run' do
+ let(:dry_run) { true }
+
+ it 'returns pipeline creation error' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['merged_yaml']).to eq(nil)
+ expect(json_response['valid']).to eq(false)
+ expect(json_response['errors']).to eq(['Insufficient permissions to create a new pipeline'])
+ end
+ end
+
+ context 'when running static validation' do
+ let(:dry_run) { false }
+
+ before do
+ project.repository.create_file(
+ project.creator,
+ 'another-gitlab-ci.yml',
+ included_content,
+ message: 'Automatically created another-gitlab-ci.yml',
+ branch_name: 'master'
+ )
+ end
+
+ it_behaves_like 'valid project config'
+ end
+ end
+ end
+
+ context 'when authenticated as project guest' do
+ before do
+ project.add_guest(api_user)
+ end
+
+ it 'returns authentication error' do
+ ci_lint
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'when authenticated as project developer' do
+ before do
+ project.add_developer(api_user)
+ end
+
+ context 'with valid .gitlab-ci.yml content' do
+ before do
+ project.repository.create_file(
+ project.creator,
+ 'another-gitlab-ci.yml',
+ included_content,
+ message: 'Automatically created another-gitlab-ci.yml',
+ branch_name: 'master'
+ )
+ end
+
+ context 'when running as dry run' do
+ let(:dry_run) { true }
+
+ it_behaves_like 'valid project config'
+ end
+
+ context 'when running static validation' do
+ let(:dry_run) { false }
+
+ it_behaves_like 'valid project config'
+ end
+ end
+
+ context 'with invalid .gitlab-ci.yml content' do
+ let(:yaml_content) do
+ { image: 'ruby:2.7', services: ['postgres'] }.to_yaml
+ end
+
+ context 'when running as dry run' do
+ let(:dry_run) { true }
+
+ it_behaves_like 'invalid project config'
+ end
+
+ context 'when running static validation' do
+ let(:dry_run) { false }
+
+ it_behaves_like 'invalid project config'
+ end
+ end
+ end
+ end
end
diff --git a/spec/requests/api/maven_packages_spec.rb b/spec/requests/api/maven_packages_spec.rb
index 37748fe5ea7..f9ba819c9aa 100644
--- a/spec/requests/api/maven_packages_spec.rb
+++ b/spec/requests/api/maven_packages_spec.rb
@@ -92,15 +92,30 @@ RSpec.describe API::MavenPackages do
end
shared_examples 'downloads with a deploy token' do
- it 'allows download with deploy token' do
- download_file(
- package_file.file_name,
- {},
- Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => deploy_token.token
- )
+ context 'successful download' do
+ subject do
+ download_file(
+ package_file.file_name,
+ {},
+ Gitlab::Auth::AuthFinders::DEPLOY_TOKEN_HEADER => deploy_token.token
+ )
+ end
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
+ it 'allows download with deploy token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+
+ it 'allows download with deploy token with only write_package_registry scope' do
+ deploy_token.update!(read_package_registry: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
end
end
@@ -355,6 +370,15 @@ RSpec.describe API::MavenPackages do
expect(response).to have_gitlab_http_status(:ok)
expect(response.media_type).to eq('application/octet-stream')
end
+
+ it 'returns the file with only write_package_registry scope' do
+ deploy_token_for_group.update!(read_package_registry: false)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
end
end
@@ -601,7 +625,7 @@ RSpec.describe API::MavenPackages do
upload_file(params: params.merge(job_token: job.token))
expect(response).to have_gitlab_http_status(:ok)
- expect(project.reload.packages.last.build_info.pipeline).to eq job.pipeline
+ expect(project.reload.packages.last.original_build_info.pipeline).to eq job.pipeline
end
it 'rejects upload without running job token' do
diff --git a/spec/requests/api/members_spec.rb b/spec/requests/api/members_spec.rb
index 047b9423906..919c8d29406 100644
--- a/spec/requests/api/members_spec.rb
+++ b/spec/requests/api/members_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe API::Members do
let(:developer) { create(:user) }
let(:access_requester) { create(:user) }
let(:stranger) { create(:user) }
+ let(:user_with_minimal_access) { create(:user) }
let(:project) do
create(:project, :public, creator_id: maintainer.id, namespace: maintainer.namespace) do |project|
@@ -20,6 +21,7 @@ RSpec.describe API::Members do
create(:group, :public) do |group|
group.add_developer(developer)
group.add_owner(maintainer)
+ create(:group_member, :minimal_access, source: group, user: user_with_minimal_access)
group.request_access(access_requester)
end
end
diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb
index 506607f4cc2..e7005bd3ec5 100644
--- a/spec/requests/api/merge_requests_spec.rb
+++ b/spec/requests/api/merge_requests_spec.rb
@@ -1312,13 +1312,44 @@ RSpec.describe API::MergeRequests do
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/changes' do
- let_it_be(:merge_request) { create(:merge_request, :simple, author: user, assignees: [user], source_project: project, target_project: project, source_branch: 'markdown', title: "Test", created_at: base_time) }
+ let_it_be(:merge_request) do
+ create(
+ :merge_request,
+ :simple,
+ author: user,
+ assignees: [user],
+ source_project: project,
+ target_project: project,
+ source_branch: 'markdown',
+ title: "Test",
+ created_at: base_time
+ )
+ end
- it 'returns the change information of the merge_request' do
- get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
+ shared_examples 'find an existing merge request' do
+ it 'returns the change information of the merge_request' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['changes'].size).to eq(merge_request.diffs.size)
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['changes'].size).to eq(merge_request.diffs.size)
+ expect(json_response['overflow']).to be_falsy
+ end
+ end
+
+ shared_examples 'accesses diffs via raw_diffs' do
+ let(:params) { {} }
+
+ it 'as expected' do
+ expect_any_instance_of(MergeRequest) do |merge_request|
+ expect(merge_request).to receive(:raw_diffs).and_call_original
+ end
+
+ expect_any_instance_of(MergeRequest) do |merge_request|
+ expect(merge_request).not_to receive(:diffs)
+ end
+
+ get(api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user), params: params)
+ end
end
it 'returns a 404 when merge_request_iid not found' do
@@ -1331,6 +1362,53 @@ RSpec.describe API::MergeRequests do
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ it_behaves_like 'find an existing merge request'
+ it_behaves_like 'accesses diffs via raw_diffs'
+
+ it 'returns the overflow status as false' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['overflow']).to be_falsy
+ end
+
+ context 'when using DB-backed diffs via feature flag' do
+ before do
+ stub_feature_flags(mrc_api_use_raw_diffs_from_gitaly: false)
+ end
+
+ it_behaves_like 'find an existing merge request'
+
+ it 'accesses diffs via DB-backed diffs.diffs' do
+ expect_any_instance_of(MergeRequest) do |merge_request|
+ expect(merge_request).to receive(:diffs).and_call_original
+ end
+
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
+ end
+
+ context 'when the diff_collection has overflowed its size limits' do
+ before do
+ expect_next_instance_of(Gitlab::Git::DiffCollection) do |diff_collection|
+ expect(diff_collection).to receive(:overflow?).and_return(true)
+ end
+ end
+
+ it 'returns the overflow status as true' do
+ get api("/projects/#{project.id}/merge_requests/#{merge_request.iid}/changes", user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['overflow']).to be_truthy
+ end
+ end
+
+ context 'when access_raw_diffs is passed as an option' do
+ it_behaves_like 'accesses diffs via raw_diffs' do
+ let(:params) { { access_raw_diffs: true } }
+ end
+ end
+ end
end
describe 'GET /projects/:id/merge_requests/:merge_request_iid/pipelines' do
diff --git a/spec/requests/api/npm_instance_packages_spec.rb b/spec/requests/api/npm_instance_packages_spec.rb
new file mode 100644
index 00000000000..8299717b5c7
--- /dev/null
+++ b/spec/requests/api/npm_instance_packages_spec.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::NpmInstancePackages do
+ include_context 'npm api setup'
+
+ describe 'GET /api/v4/packages/npm/*package_name' do
+ it_behaves_like 'handling get metadata requests' do
+ let(:url) { api("/packages/npm/#{package_name}") }
+ end
+ end
+
+ describe 'GET /api/v4/packages/npm/-/package/*package_name/dist-tags' do
+ it_behaves_like 'handling get dist tags requests', scope: :instance do
+ let(:url) { api("/packages/npm/-/package/#{package_name}/dist-tags") }
+ end
+ end
+
+ describe 'PUT /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do
+ it_behaves_like 'handling create dist tag requests', scope: :instance do
+ let(:url) { api("/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
+ end
+ end
+
+ describe 'DELETE /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do
+ it_behaves_like 'handling delete dist tag requests', scope: :instance do
+ let(:url) { api("/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
+ end
+ end
+end
diff --git a/spec/requests/api/npm_packages_spec.rb b/spec/requests/api/npm_packages_spec.rb
deleted file mode 100644
index 8a3ccd7c6e3..00000000000
--- a/spec/requests/api/npm_packages_spec.rb
+++ /dev/null
@@ -1,556 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe API::NpmPackages do
- include PackagesManagerApiSpecHelpers
- include HttpBasicAuthHelpers
-
- let_it_be(:user) { create(:user) }
- let_it_be(:group) { create(:group) }
- let_it_be(:project, reload: true) { create(:project, :public, namespace: group) }
- let_it_be(:package, reload: true) { create(:npm_package, project: project) }
- let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) }
- let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
- let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) }
- let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
- let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
-
- before do
- project.add_developer(user)
- end
-
- shared_examples 'a package that requires auth' do
- it 'returns the package info with oauth token' do
- get_package_with_token(package)
-
- expect_a_valid_package_response
- end
-
- it 'returns the package info with running job token' do
- get_package_with_job_token(package)
-
- expect_a_valid_package_response
- end
-
- it 'denies request without running job token' do
- job.update!(status: :success)
- get_package_with_job_token(package)
-
- expect(response).to have_gitlab_http_status(:unauthorized)
- end
-
- it 'denies request without oauth token' do
- get_package(package)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
-
- it 'returns the package info with deploy token' do
- get_package_with_deploy_token(package)
-
- expect_a_valid_package_response
- end
- end
-
- describe 'GET /api/v4/packages/npm/*package_name' do
- let_it_be(:package_dependency_link1) { create(:packages_dependency_link, package: package, dependency_type: :dependencies) }
- let_it_be(:package_dependency_link2) { create(:packages_dependency_link, package: package, dependency_type: :devDependencies) }
- let_it_be(:package_dependency_link3) { create(:packages_dependency_link, package: package, dependency_type: :bundleDependencies) }
- let_it_be(:package_dependency_link4) { create(:packages_dependency_link, package: package, dependency_type: :peerDependencies) }
-
- shared_examples 'returning the npm package info' do
- it 'returns the package info' do
- get_package(package)
-
- expect_a_valid_package_response
- end
- end
-
- shared_examples 'returning forbidden for unknown package' do
- context 'with an unknown package' do
- it 'returns forbidden' do
- get api("/packages/npm/unknown")
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
- end
-
- context 'a public project' do
- it_behaves_like 'returning the npm package info'
-
- context 'with application setting enabled' do
- before do
- stub_application_setting(npm_package_requests_forwarding: true)
- end
-
- it_behaves_like 'returning the npm package info'
-
- context 'with unknown package' do
- subject { get api("/packages/npm/unknown") }
-
- it 'returns a redirect' do
- subject
-
- expect(response).to have_gitlab_http_status(:found)
- expect(response.headers['Location']).to eq('https://registry.npmjs.org/unknown')
- end
-
- it_behaves_like 'a gitlab tracking event', described_class.name, 'npm_request_forward'
- end
- end
-
- context 'with application setting disabled' do
- before do
- stub_application_setting(npm_package_requests_forwarding: false)
- end
-
- it_behaves_like 'returning the npm package info'
-
- it_behaves_like 'returning forbidden for unknown package'
- end
-
- context 'project path with a dot' do
- before do
- project.update!(path: 'foo.bar')
- end
-
- it_behaves_like 'returning the npm package info'
- end
- end
-
- context 'internal project' do
- before do
- project.team.truncate
- project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- end
-
- it_behaves_like 'a package that requires auth'
- end
-
- context 'private project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- it_behaves_like 'a package that requires auth'
-
- it 'denies request when not enough permissions' do
- project.add_guest(user)
-
- get_package_with_token(package)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- def get_package(package, params = {}, headers = {})
- get api("/packages/npm/#{package.name}"), params: params, headers: headers
- end
-
- def get_package_with_token(package, params = {})
- get_package(package, params.merge(access_token: token.token))
- end
-
- def get_package_with_job_token(package, params = {})
- get_package(package, params.merge(job_token: job.token))
- end
-
- def get_package_with_deploy_token(package, params = {})
- get_package(package, {}, build_token_auth_header(deploy_token.token))
- end
- end
-
- describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
- let_it_be(:package_file) { package.package_files.first }
-
- shared_examples 'a package file that requires auth' do
- it 'returns the file with an access token' do
- get_file_with_token(package_file)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
-
- it 'returns the file with a job token' do
- get_file_with_job_token(package_file)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
-
- it 'denies download with no token' do
- get_file(package_file)
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
-
- context 'a public project' do
- subject { get_file(package_file) }
-
- it 'returns the file with no token needed' do
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/octet-stream')
- end
-
- it_behaves_like 'a package tracking event', described_class.name, 'pull_package'
- end
-
- context 'private project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- it_behaves_like 'a package file that requires auth'
-
- it 'denies download when not enough permissions' do
- project.add_guest(user)
-
- get_file_with_token(package_file)
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'internal project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
- end
-
- it_behaves_like 'a package file that requires auth'
- end
-
- def get_file(package_file, params = {})
- get api("/projects/#{project.id}/packages/npm/" \
- "#{package_file.package.name}/-/#{package_file.file_name}"), params: params
- end
-
- def get_file_with_token(package_file, params = {})
- get_file(package_file, params.merge(access_token: token.token))
- end
-
- def get_file_with_job_token(package_file, params = {})
- get_file(package_file, params.merge(job_token: job.token))
- end
- end
-
- describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do
- RSpec.shared_examples 'handling invalid record with 400 error' do
- it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
- expect { upload_package_with_token(package_name, params) }
- .not_to change { project.packages.count }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
-
- context 'when params are correct' do
- context 'invalid package record' do
- context 'unscoped package' do
- let(:package_name) { 'my_unscoped_package' }
- let(:params) { upload_params(package_name: package_name) }
-
- it_behaves_like 'handling invalid record with 400 error'
-
- context 'with empty versions' do
- let(:params) { upload_params(package_name: package_name).merge!(versions: {}) }
-
- it 'throws a 400 error' do
- expect { upload_package_with_token(package_name, params) }
- .not_to change { project.packages.count }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- end
- end
- end
-
- context 'invalid package name' do
- let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" }
- let(:params) { upload_params(package_name: package_name) }
-
- it_behaves_like 'handling invalid record with 400 error'
- end
-
- context 'invalid package version' do
- using RSpec::Parameterized::TableSyntax
-
- let(:package_name) { "@#{group.path}/my_package_name" }
-
- where(:version) do
- [
- '1',
- '1.2',
- '1./2.3',
- '../../../../../1.2.3',
- '%2e%2e%2f1.2.3'
- ]
- end
-
- with_them do
- let(:params) { upload_params(package_name: package_name, package_version: version) }
-
- it_behaves_like 'handling invalid record with 400 error'
- end
- end
- end
-
- context 'scoped package' do
- let(:package_name) { "@#{group.path}/my_package_name" }
- let(:params) { upload_params(package_name: package_name) }
-
- context 'with access token' do
- subject { upload_package_with_token(package_name, params) }
-
- it_behaves_like 'a package tracking event', described_class.name, 'push_package'
-
- it 'creates npm package with file' do
- expect { subject }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
- .and change { Packages::Tag.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
- end
-
- it 'creates npm package with file with job token' do
- expect { upload_package_with_job_token(package_name, params) }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'with an authenticated job token' do
- let!(:job) { create(:ci_build, user: user) }
-
- before do
- Grape::Endpoint.before_each do |endpoint|
- expect(endpoint).to receive(:current_authenticated_job) { job }
- end
- end
-
- after do
- Grape::Endpoint.before_each nil
- end
-
- it 'creates the package metadata' do
- upload_package_with_token(package_name, params)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(project.reload.packages.find(json_response['id']).build_info.pipeline).to eq job.pipeline
- end
- end
- end
-
- context 'package creation fails' do
- let(:package_name) { "@#{group.path}/my_package_name" }
- let(:params) { upload_params(package_name: package_name) }
-
- it 'returns an error if the package already exists' do
- create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name")
- expect { upload_package_with_token(package_name, params) }
- .not_to change { project.packages.count }
-
- expect(response).to have_gitlab_http_status(:forbidden)
- end
- end
-
- context 'with dependencies' do
- let(:package_name) { "@#{group.path}/my_package_name" }
- let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_duplicated_packages.json') }
-
- it 'creates npm package with file and dependencies' do
- expect { upload_package_with_token(package_name, params) }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
- .and change { Packages::Dependency.count}.by(4)
- .and change { Packages::DependencyLink.count}.by(6)
-
- expect(response).to have_gitlab_http_status(:ok)
- end
-
- context 'with existing dependencies' do
- before do
- name = "@#{group.path}/existing_package"
- upload_package_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json'))
- end
-
- it 'reuses them' do
- expect { upload_package_with_token(package_name, params) }
- .to change { project.packages.count }.by(1)
- .and change { Packages::PackageFile.count }.by(1)
- .and not_change { Packages::Dependency.count}
- .and change { Packages::DependencyLink.count}.by(6)
- end
- end
- end
- end
-
- def upload_package(package_name, params = {})
- put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params
- end
-
- def upload_package_with_token(package_name, params = {})
- upload_package(package_name, params.merge(access_token: token.token))
- end
-
- def upload_package_with_job_token(package_name, params = {})
- upload_package(package_name, params.merge(job_token: job.token))
- end
-
- def upload_params(package_name:, package_version: '1.0.1', file: 'npm/payload.json')
- Gitlab::Json.parse(fixture_file("packages/#{file}")
- .gsub('@root/npm-test', package_name)
- .gsub('1.0.1', package_version))
- end
- end
-
- describe 'GET /api/v4/packages/npm/-/package/*package_name/dist-tags' do
- let_it_be(:package_tag1) { create(:packages_tag, package: package) }
- let_it_be(:package_tag2) { create(:packages_tag, package: package) }
-
- let(:package_name) { package.name }
- let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags" }
-
- subject { get api(url) }
-
- context 'with public project' do
- context 'with authenticated user' do
- subject { get api(url, personal_access_token: personal_access_token) }
-
- it_behaves_like 'returns package tags', :maintainer
- it_behaves_like 'returns package tags', :developer
- it_behaves_like 'returns package tags', :reporter
- it_behaves_like 'returns package tags', :guest
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'returns package tags', :no_type
- end
- end
-
- context 'with private project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- context 'with authenticated user' do
- subject { get api(url, personal_access_token: personal_access_token) }
-
- it_behaves_like 'returns package tags', :maintainer
- it_behaves_like 'returns package tags', :developer
- it_behaves_like 'returns package tags', :reporter
- it_behaves_like 'rejects package tags access', :guest, :forbidden
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'rejects package tags access', :no_type, :forbidden
- end
- end
- end
-
- describe 'PUT /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do
- let_it_be(:tag_name) { 'test' }
-
- let(:package_name) { package.name }
- let(:version) { package.version }
- let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}" }
-
- subject { put api(url), env: { 'api.request.body': version } }
-
- context 'with public project' do
- context 'with authenticated user' do
- subject { put api(url, personal_access_token: personal_access_token), env: { 'api.request.body': version } }
-
- it_behaves_like 'create package tag', :maintainer
- it_behaves_like 'create package tag', :developer
- it_behaves_like 'rejects package tags access', :reporter, :forbidden
- it_behaves_like 'rejects package tags access', :guest, :forbidden
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'rejects package tags access', :no_type, :unauthorized
- end
- end
-
- context 'with private project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- context 'with authenticated user' do
- subject { put api(url, personal_access_token: personal_access_token), env: { 'api.request.body': version } }
-
- it_behaves_like 'create package tag', :maintainer
- it_behaves_like 'create package tag', :developer
- it_behaves_like 'rejects package tags access', :reporter, :forbidden
- it_behaves_like 'rejects package tags access', :guest, :forbidden
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'rejects package tags access', :no_type, :unauthorized
- end
- end
- end
-
- describe 'DELETE /api/v4/packages/npm/-/package/*package_name/dist-tags/:tag' do
- let_it_be(:package_tag) { create(:packages_tag, package: package) }
-
- let(:package_name) { package.name }
- let(:tag_name) { package_tag.name }
- let(:url) { "/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}" }
-
- subject { delete api(url) }
-
- context 'with public project' do
- context 'with authenticated user' do
- subject { delete api(url, personal_access_token: personal_access_token) }
-
- it_behaves_like 'delete package tag', :maintainer
- it_behaves_like 'rejects package tags access', :developer, :forbidden
- it_behaves_like 'rejects package tags access', :reporter, :forbidden
- it_behaves_like 'rejects package tags access', :guest, :forbidden
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'rejects package tags access', :no_type, :unauthorized
- end
- end
-
- context 'with private project' do
- before do
- project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
- end
-
- context 'with authenticated user' do
- subject { delete api(url, personal_access_token: personal_access_token) }
-
- it_behaves_like 'delete package tag', :maintainer
- it_behaves_like 'rejects package tags access', :developer, :forbidden
- it_behaves_like 'rejects package tags access', :reporter, :forbidden
- it_behaves_like 'rejects package tags access', :guest, :forbidden
- end
-
- context 'with unauthenticated user' do
- it_behaves_like 'rejects package tags access', :no_type, :unauthorized
- end
- end
- end
-
- def expect_a_valid_package_response
- expect(response).to have_gitlab_http_status(:ok)
- expect(response.media_type).to eq('application/json')
- expect(response).to match_response_schema('public_api/v4/packages/npm_package')
- expect(json_response['name']).to eq(package.name)
- expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version')
- ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
- expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any
- end
- expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags')
- end
-end
diff --git a/spec/requests/api/npm_project_packages_spec.rb b/spec/requests/api/npm_project_packages_spec.rb
new file mode 100644
index 00000000000..1421f20ac28
--- /dev/null
+++ b/spec/requests/api/npm_project_packages_spec.rb
@@ -0,0 +1,281 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::NpmProjectPackages do
+ include_context 'npm api setup'
+
+ describe 'GET /api/v4/projects/:id/packages/npm/*package_name' do
+ it_behaves_like 'handling get metadata requests' do
+ let(:url) { api("/projects/#{project.id}/packages/npm/#{package_name}") }
+ end
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags' do
+ it_behaves_like 'handling get dist tags requests' do
+ let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags") }
+ end
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do
+ it_behaves_like 'handling create dist tag requests' do
+ let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
+ end
+ end
+
+ describe 'DELETE /api/v4/projects/:id/packages/npm/-/package/*package_name/dist-tags/:tag' do
+ it_behaves_like 'handling delete dist tag requests' do
+ let(:url) { api("/projects/#{project.id}/packages/npm/-/package/#{package_name}/dist-tags/#{tag_name}") }
+ end
+ end
+
+ describe 'GET /api/v4/projects/:id/packages/npm/*package_name/-/*file_name' do
+ let_it_be(:package_file) { package.package_files.first }
+
+ let(:params) { {} }
+ let(:url) { api("/projects/#{project.id}/packages/npm/#{package_file.package.name}/-/#{package_file.file_name}") }
+
+ subject { get(url, params: params) }
+
+ shared_examples 'a package file that requires auth' do
+ it 'denies download with no token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'with access token' do
+ let(:params) { { access_token: token.token } }
+
+ it 'returns the file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+ end
+
+ context 'with job token' do
+ let(:params) { { job_token: job.token } }
+
+ it 'returns the file' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+ end
+ end
+
+ context 'a public project' do
+ it 'returns the file with no token needed' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/octet-stream')
+ end
+
+ it_behaves_like 'a package tracking event', 'API::NpmPackages', 'pull_package'
+ end
+
+ context 'private project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it_behaves_like 'a package file that requires auth'
+
+ context 'with guest' do
+ let(:params) { { access_token: token.token } }
+
+ it 'denies download when not enough permissions' do
+ project.add_guest(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ context 'internal project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ end
+
+ it_behaves_like 'a package file that requires auth'
+ end
+ end
+
+ describe 'PUT /api/v4/projects/:id/packages/npm/:package_name' do
+ RSpec.shared_examples 'handling invalid record with 400 error' do
+ it 'handles an ActiveRecord::RecordInvalid exception with 400 error' do
+ expect { upload_package_with_token(package_name, params) }
+ .not_to change { project.packages.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+
+ context 'when params are correct' do
+ context 'invalid package record' do
+ context 'unscoped package' do
+ let(:package_name) { 'my_unscoped_package' }
+ let(:params) { upload_params(package_name: package_name) }
+
+ it_behaves_like 'handling invalid record with 400 error'
+
+ context 'with empty versions' do
+ let(:params) { upload_params(package_name: package_name).merge!(versions: {}) }
+
+ it 'throws a 400 error' do
+ expect { upload_package_with_token(package_name, params) }
+ .not_to change { project.packages.count }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+ end
+ end
+
+ context 'invalid package name' do
+ let(:package_name) { "@#{group.path}/my_inv@@lid_package_name" }
+ let(:params) { upload_params(package_name: package_name) }
+
+ it_behaves_like 'handling invalid record with 400 error'
+ end
+
+ context 'invalid package version' do
+ using RSpec::Parameterized::TableSyntax
+
+ let(:package_name) { "@#{group.path}/my_package_name" }
+
+ where(:version) do
+ [
+ '1',
+ '1.2',
+ '1./2.3',
+ '../../../../../1.2.3',
+ '%2e%2e%2f1.2.3'
+ ]
+ end
+
+ with_them do
+ let(:params) { upload_params(package_name: package_name, package_version: version) }
+
+ it_behaves_like 'handling invalid record with 400 error'
+ end
+ end
+ end
+
+ context 'scoped package' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+ let(:params) { upload_params(package_name: package_name) }
+
+ context 'with access token' do
+ subject { upload_package_with_token(package_name, params) }
+
+ it_behaves_like 'a package tracking event', 'API::NpmPackages', 'push_package'
+
+ it 'creates npm package with file' do
+ expect { subject }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ .and change { Packages::Tag.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+
+ it 'creates npm package with file with job token' do
+ expect { upload_package_with_job_token(package_name, params) }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'with an authenticated job token' do
+ let!(:job) { create(:ci_build, user: user) }
+
+ before do
+ Grape::Endpoint.before_each do |endpoint|
+ expect(endpoint).to receive(:current_authenticated_job) { job }
+ end
+ end
+
+ after do
+ Grape::Endpoint.before_each nil
+ end
+
+ it 'creates the package metadata' do
+ upload_package_with_token(package_name, params)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(project.reload.packages.find(json_response['id']).original_build_info.pipeline).to eq job.pipeline
+ end
+ end
+ end
+
+ context 'package creation fails' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+ let(:params) { upload_params(package_name: package_name) }
+
+ it 'returns an error if the package already exists' do
+ create(:npm_package, project: project, version: '1.0.1', name: "@#{group.path}/my_package_name")
+ expect { upload_package_with_token(package_name, params) }
+ .not_to change { project.packages.count }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+
+ context 'with dependencies' do
+ let(:package_name) { "@#{group.path}/my_package_name" }
+ let(:params) { upload_params(package_name: package_name, file: 'npm/payload_with_duplicated_packages.json') }
+
+ it 'creates npm package with file and dependencies' do
+ expect { upload_package_with_token(package_name, params) }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ .and change { Packages::Dependency.count}.by(4)
+ .and change { Packages::DependencyLink.count}.by(6)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'with existing dependencies' do
+ before do
+ name = "@#{group.path}/existing_package"
+ upload_package_with_token(name, upload_params(package_name: name, file: 'npm/payload_with_duplicated_packages.json'))
+ end
+
+ it 'reuses them' do
+ expect { upload_package_with_token(package_name, params) }
+ .to change { project.packages.count }.by(1)
+ .and change { Packages::PackageFile.count }.by(1)
+ .and not_change { Packages::Dependency.count}
+ .and change { Packages::DependencyLink.count}.by(6)
+ end
+ end
+ end
+ end
+
+ def upload_package(package_name, params = {})
+ put api("/projects/#{project.id}/packages/npm/#{package_name.sub('/', '%2f')}"), params: params
+ end
+
+ def upload_package_with_token(package_name, params = {})
+ upload_package(package_name, params.merge(access_token: token.token))
+ end
+
+ def upload_package_with_job_token(package_name, params = {})
+ upload_package(package_name, params.merge(job_token: job.token))
+ end
+
+ def upload_params(package_name:, package_version: '1.0.1', file: 'npm/payload.json')
+ Gitlab::Json.parse(fixture_file("packages/#{file}")
+ .gsub('@root/npm-test', package_name)
+ .gsub('1.0.1', package_version))
+ end
+ end
+end
diff --git a/spec/requests/api/personal_access_tokens_spec.rb b/spec/requests/api/personal_access_tokens_spec.rb
new file mode 100644
index 00000000000..ccc5f322ff9
--- /dev/null
+++ b/spec/requests/api/personal_access_tokens_spec.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe API::PersonalAccessTokens do
+ let_it_be(:path) { '/personal_access_tokens' }
+ let_it_be(:token1) { create(:personal_access_token) }
+ let_it_be(:token2) { create(:personal_access_token) }
+ let_it_be(:current_user) { create(:user) }
+
+ describe 'GET /personal_access_tokens' do
+ context 'logged in as an Administrator' do
+ let_it_be(:current_user) { create(:admin) }
+
+ it 'returns all PATs by default' do
+ get api(path, current_user)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.count).to eq(PersonalAccessToken.all.count)
+ end
+
+ context 'filtered with user_id parameter' do
+ it 'returns only PATs belonging to that user' do
+ get api(path, current_user), params: { user_id: token1.user.id }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.count).to eq(1)
+ expect(json_response.first['user_id']).to eq(token1.user.id)
+ end
+ end
+
+ context 'logged in as a non-Administrator' do
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:user) { create(:user) }
+ let_it_be(:token) { create(:personal_access_token, user: current_user)}
+ let_it_be(:other_token) { create(:personal_access_token, user: user) }
+
+ it 'returns all PATs belonging to the signed-in user' do
+ get api(path, current_user, personal_access_token: token)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.count).to eq(1)
+ expect(json_response.map { |r| r['user_id'] }.uniq).to contain_exactly(current_user.id)
+ end
+
+ context 'filtered with user_id parameter' do
+ it 'returns PATs belonging to the specific user' do
+ get api(path, current_user, personal_access_token: token), params: { user_id: current_user.id }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.count).to eq(1)
+ expect(json_response.map { |r| r['user_id'] }.uniq).to contain_exactly(current_user.id)
+ end
+
+ it 'is unauthorized if filtered by a user other than current_user' do
+ get api(path, current_user, personal_access_token: token), params: { user_id: user.id }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+
+ context 'not authenticated' do
+ it 'is forbidden' do
+ get api(path)
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+
+ describe 'DELETE /personal_access_tokens/:id' do
+ let(:path) { "/personal_access_tokens/#{token1.id}" }
+
+ context 'when current_user is an administrator', :enable_admin_mode do
+ let_it_be(:admin_user) { create(:admin) }
+ let_it_be(:admin_token) { create(:personal_access_token, user: admin_user) }
+ let_it_be(:admin_path) { "/personal_access_tokens/#{admin_token.id}" }
+
+ it 'revokes a different users token' do
+ delete api(path, admin_user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ expect(token1.reload.revoked?).to be true
+ end
+
+ it 'revokes their own token' do
+ delete api(admin_path, admin_user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+
+ context 'when current_user is not an administrator' do
+ let_it_be(:user_token) { create(:personal_access_token, user: current_user) }
+ let_it_be(:user_token_path) { "/personal_access_tokens/#{user_token.id}" }
+
+ it 'fails revokes a different users token' do
+ delete api(path, current_user)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'revokes their own token' do
+ delete api(user_token_path, current_user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/project_container_repositories_spec.rb b/spec/requests/api/project_container_repositories_spec.rb
index 34476b10576..15871426ec5 100644
--- a/spec/requests/api/project_container_repositories_spec.rb
+++ b/spec/requests/api/project_container_repositories_spec.rb
@@ -299,7 +299,7 @@ RSpec.describe API::ProjectContainerRepositories do
it_behaves_like 'rejected container repository access', :reporter, :forbidden
it_behaves_like 'rejected container repository access', :anonymous, :not_found
- context 'for developer' do
+ context 'for developer', :snowplow do
let(:api_user) { developer }
context 'when there are multiple tags' do
@@ -310,11 +310,11 @@ RSpec.describe API::ProjectContainerRepositories do
it 'properly removes tag' do
expect(service).to receive(:execute).with(root_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service }
- expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag', {})
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect_snowplow_event(category: described_class.name, action: 'delete_tag')
end
end
@@ -326,11 +326,11 @@ RSpec.describe API::ProjectContainerRepositories do
it 'properly removes tag' do
expect(service).to receive(:execute).with(root_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(root_repository.project, api_user, tags: %w[rootA]) { service }
- expect(Gitlab::Tracking).to receive(:event).with(described_class.name, 'delete_tag', {})
subject
expect(response).to have_gitlab_http_status(:ok)
+ expect_snowplow_event(category: described_class.name, action: 'delete_tag')
end
end
end
diff --git a/spec/requests/api/project_export_spec.rb b/spec/requests/api/project_export_spec.rb
index 09d295afbea..ac24aeee52c 100644
--- a/spec/requests/api/project_export_spec.rb
+++ b/spec/requests/api/project_export_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe API::ProjectExport, :clean_gitlab_redis_cache do
let(:export_path) { "#{Dir.tmpdir}/project_export_spec" }
before do
- allow_any_instance_of(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
+ allow(Gitlab::ImportExport).to receive(:storage_path).and_return(export_path)
allow_next_instance_of(ProjectExportWorker) do |job|
allow(job).to receive(:jid).and_return(SecureRandom.hex(8))
end
diff --git a/spec/requests/api/project_hooks_spec.rb b/spec/requests/api/project_hooks_spec.rb
index 3b2a7895630..b5aedde2b2e 100644
--- a/spec/requests/api/project_hooks_spec.rb
+++ b/spec/requests/api/project_hooks_spec.rb
@@ -41,6 +41,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response.first['pipeline_events']).to eq(true)
expect(json_response.first['wiki_page_events']).to eq(true)
expect(json_response.first['deployment_events']).to eq(true)
+ expect(json_response.first['releases_events']).to eq(true)
expect(json_response.first['enable_ssl_verification']).to eq(true)
expect(json_response.first['push_events_branch_filter']).to eq('master')
end
@@ -72,6 +73,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
+ expect(json_response['releases_events']).to eq(hook.releases_events)
expect(json_response['deployment_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
end
@@ -97,7 +99,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
post(api("/projects/#{project.id}/hooks", user),
params: { url: "http://example.com", issues_events: true,
confidential_issues_events: true, wiki_page_events: true,
- job_events: true, deployment_events: true,
+ job_events: true, deployment_events: true, releases_events: true,
push_events_branch_filter: 'some-feature-branch' })
end.to change {project.hooks.count}.by(1)
@@ -114,6 +116,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['pipeline_events']).to eq(false)
expect(json_response['wiki_page_events']).to eq(true)
expect(json_response['deployment_events']).to eq(true)
+ expect(json_response['releases_events']).to eq(true)
expect(json_response['enable_ssl_verification']).to eq(true)
expect(json_response['push_events_branch_filter']).to eq('some-feature-branch')
expect(json_response).not_to include('token')
@@ -169,6 +172,7 @@ RSpec.describe API::ProjectHooks, 'ProjectHooks' do
expect(json_response['job_events']).to eq(hook.job_events)
expect(json_response['pipeline_events']).to eq(hook.pipeline_events)
expect(json_response['wiki_page_events']).to eq(hook.wiki_page_events)
+ expect(json_response['releases_events']).to eq(hook.releases_events)
expect(json_response['enable_ssl_verification']).to eq(hook.enable_ssl_verification)
end
diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb
index 2abcb39a1c8..4a792fc218d 100644
--- a/spec/requests/api/projects_spec.rb
+++ b/spec/requests/api/projects_spec.rb
@@ -890,7 +890,7 @@ RSpec.describe API::Projects do
expect(response).to have_gitlab_http_status(:created)
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker issues_enabled merge_requests_enabled wiki_enabled storage_version].include?(k)
+ next if %i[has_external_issue_tracker has_external_wiki issues_enabled merge_requests_enabled wiki_enabled storage_version].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -1309,7 +1309,7 @@ RSpec.describe API::Projects do
expect(response).to have_gitlab_http_status(:created)
project.each_pair do |k, v|
- next if %i[has_external_issue_tracker path storage_version].include?(k)
+ next if %i[has_external_issue_tracker has_external_wiki path storage_version].include?(k)
expect(json_response[k.to_s]).to eq(v)
end
@@ -2659,6 +2659,7 @@ RSpec.describe API::Projects do
project_param = {
container_expiration_policy_attributes: {
cadence: '1month',
+ enabled: true,
keep_n: 1,
name_regex_keep: '['
}
diff --git a/spec/requests/api/release/links_spec.rb b/spec/requests/api/release/links_spec.rb
index 82d0d64eba4..c03dd0331cf 100644
--- a/spec/requests/api/release/links_spec.rb
+++ b/spec/requests/api/release/links_spec.rb
@@ -146,7 +146,7 @@ RSpec.describe API::Release::Links do
specify do
get api("/projects/#{project.id}/releases/v0.1/assets/links/#{link.id}", maintainer)
- expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.namespace.path}/#{project.name}/-/releases/#{release.tag}/bin/bigfile.exe")
+ expect(json_response['direct_asset_url']).to eq("http://localhost/#{project.namespace.path}/#{project.name}/-/releases/#{release.tag}/downloads/bin/bigfile.exe")
end
end
diff --git a/spec/requests/api/releases_spec.rb b/spec/requests/api/releases_spec.rb
index e78d05835f2..58b321a255e 100644
--- a/spec/requests/api/releases_spec.rb
+++ b/spec/requests/api/releases_spec.rb
@@ -110,22 +110,6 @@ RSpec.describe API::Releases do
expect(json_response.second['commit_path']).to eq("/#{release_1.project.full_path}/-/commit/#{release_1.commit.id}")
expect(json_response.second['tag_path']).to eq("/#{release_1.project.full_path}/-/tags/#{release_1.tag}")
end
-
- it 'returns the merge requests and issues links, with correct query' do
- get api("/projects/#{project.id}/releases", maintainer)
-
- links = json_response.first['_links']
- release = json_response.first['tag_name']
- expected_query = "release_tag=#{release}&scope=all&state=opened"
- path_base = "/#{project.namespace.path}/#{project.path}"
- mr_uri = URI.parse(links['merge_requests_url'])
- issue_uri = URI.parse(links['issues_url'])
-
- expect(mr_uri.path).to eq("#{path_base}/-/merge_requests")
- expect(issue_uri.path).to eq("#{path_base}/-/issues")
- expect(mr_uri.query).to eq(expected_query)
- expect(issue_uri.query).to eq(expected_query)
- end
end
it 'returns an upcoming_release status for a future release' do
@@ -1014,6 +998,17 @@ RSpec.describe API::Releases do
end
end
+ context 'without milestones parameter' do
+ let(:params) { { name: 'some new name' } }
+
+ it 'does not change the milestone' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(returned_milestones).to match_array(['v1.0'])
+ end
+ end
+
context 'multiple milestones' do
context 'with one new' do
let!(:milestone2) { create(:milestone, project: project, title: 'milestone2') }
diff --git a/spec/requests/api/search_spec.rb b/spec/requests/api/search_spec.rb
index 05cfad9cc62..8012892a571 100644
--- a/spec/requests/api/search_spec.rb
+++ b/spec/requests/api/search_spec.rb
@@ -23,6 +23,48 @@ RSpec.describe API::Search do
end
end
+ shared_examples 'orderable by created_at' do |scope:|
+ it 'allows ordering results by created_at asc' do
+ get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'asc' }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response.count).to be > 1
+
+ created_ats = json_response.map { |r| Time.parse(r['created_at']) }
+ expect(created_ats.uniq.count).to be > 1
+
+ expect(created_ats).to eq(created_ats.sort)
+ end
+
+ it 'allows ordering results by created_at desc' do
+ get api(endpoint, user), params: { scope: scope, search: 'sortable', order_by: 'created_at', sort: 'desc' }
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(json_response.count).to be > 1
+
+ created_ats = json_response.map { |r| Time.parse(r['created_at']) }
+ expect(created_ats.uniq.count).to be > 1
+
+ expect(created_ats).to eq(created_ats.sort.reverse)
+ end
+ end
+
+ shared_examples 'issues orderable by created_at' do
+ before do
+ create_list(:issue, 3, title: 'sortable item', project: project)
+ end
+
+ it_behaves_like 'orderable by created_at', scope: :issues
+ end
+
+ shared_examples 'merge_requests orderable by created_at' do
+ before do
+ create_list(:merge_request, 3, :unique_branches, title: 'sortable item', target_project: repo_project, source_project: repo_project)
+ end
+
+ it_behaves_like 'orderable by created_at', scope: :merge_requests
+ end
+
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 }
@@ -121,6 +163,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -151,7 +195,6 @@ RSpec.describe API::Search do
context 'filter by confidentiality' do
before do
- stub_feature_flags(search_filter_by_confidential: true)
create(:issue, project: project, author: user, title: 'awesome non-confidential issue')
create(:issue, :confidential, project: project, author: user, title: 'awesome confidential issue')
end
@@ -182,6 +225,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
@@ -355,6 +400,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -375,6 +422,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
@@ -507,6 +556,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :issues
+ it_behaves_like 'issues orderable by created_at'
+
describe 'pagination' do
before do
create(:issue, project: project, title: 'another issue')
@@ -516,6 +567,14 @@ RSpec.describe API::Search do
end
end
+ context 'when requesting basic search' do
+ it 'passes the parameter to search service' do
+ expect(SearchService).to receive(:new).with(user, hash_including(basic_search: 'true'))
+
+ get api(endpoint, user), params: { scope: 'issues', search: 'awesome', basic_search: 'true' }
+ end
+ end
+
context 'for merge_requests scope' do
let(:endpoint) { "/projects/#{repo_project.id}/search" }
@@ -529,6 +588,8 @@ RSpec.describe API::Search do
it_behaves_like 'ping counters', scope: :merge_requests
+ it_behaves_like 'merge_requests orderable by created_at'
+
describe 'pagination' do
before do
create(:merge_request, source_project: repo_project, title: 'another mr', target_branch: 'another_branch')
diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb
index 8b5f74df8f8..03320549e44 100644
--- a/spec/requests/api/settings_spec.rb
+++ b/spec/requests/api/settings_spec.rb
@@ -22,6 +22,8 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['default_ci_config_path']).to be_nil
expect(json_response['sourcegraph_enabled']).to be_falsey
expect(json_response['sourcegraph_url']).to be_nil
+ expect(json_response['secret_detection_token_revocation_url']).to be_nil
+ expect(json_response['secret_detection_revocation_token_types_url']).to be_nil
expect(json_response['sourcegraph_public_only']).to be_truthy
expect(json_response['default_project_visibility']).to be_a String
expect(json_response['default_snippet_visibility']).to be_a String
@@ -40,6 +42,7 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['spam_check_endpoint_enabled']).to be_falsey
expect(json_response['spam_check_endpoint_url']).to be_nil
expect(json_response['wiki_page_max_content_bytes']).to be_a(Integer)
+ expect(json_response['require_admin_approval_after_user_signup']).to eq(true)
end
end
@@ -105,7 +108,7 @@ RSpec.describe API::Settings, 'Settings' do
enforce_terms: true,
terms: 'Hello world!',
performance_bar_allowed_group_path: group.full_path,
- diff_max_patch_bytes: 150_000,
+ diff_max_patch_bytes: 300_000,
default_branch_protection: ::Gitlab::Access::PROTECTION_DEV_CAN_MERGE,
local_markdown_version: 3,
allow_local_requests_from_web_hooks_and_services: true,
@@ -148,7 +151,7 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['enforce_terms']).to be(true)
expect(json_response['terms']).to eq('Hello world!')
expect(json_response['performance_bar_allowed_group_id']).to eq(group.id)
- expect(json_response['diff_max_patch_bytes']).to eq(150_000)
+ expect(json_response['diff_max_patch_bytes']).to eq(300_000)
expect(json_response['default_branch_protection']).to eq(Gitlab::Access::PROTECTION_DEV_CAN_MERGE)
expect(json_response['local_markdown_version']).to eq(3)
expect(json_response['allow_local_requests_from_web_hooks_and_services']).to eq(true)
@@ -377,41 +380,41 @@ RSpec.describe API::Settings, 'Settings' do
end
end
- context 'domain_blacklist settings' do
- it 'rejects domain_blacklist_enabled when domain_blacklist is empty' do
+ context 'domain_denylist settings' do
+ it 'rejects domain_denylist_enabled when domain_denylist is empty' do
put api('/application/settings', admin),
params: {
- domain_blacklist_enabled: true,
- domain_blacklist: []
+ domain_denylist_enabled: true,
+ domain_denylist: []
}
expect(response).to have_gitlab_http_status(:bad_request)
message = json_response["message"]
- expect(message["domain_blacklist"]).to eq(["Domain blacklist cannot be empty if Blacklist is enabled."])
+ expect(message["domain_denylist"]).to eq(["Domain denylist cannot be empty if denylist is enabled."])
end
- it 'allows array for domain_blacklist' do
+ it 'allows array for domain_denylist' do
put api('/application/settings', admin),
params: {
- domain_blacklist_enabled: true,
- domain_blacklist: ['domain1.com', 'domain2.com']
+ domain_denylist_enabled: true,
+ domain_denylist: ['domain1.com', 'domain2.com']
}
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['domain_blacklist_enabled']).to be(true)
- expect(json_response['domain_blacklist']).to eq(['domain1.com', 'domain2.com'])
+ expect(json_response['domain_denylist_enabled']).to be(true)
+ expect(json_response['domain_denylist']).to eq(['domain1.com', 'domain2.com'])
end
- it 'allows a string for domain_blacklist' do
+ it 'allows a string for domain_denylist' do
put api('/application/settings', admin),
params: {
- domain_blacklist_enabled: true,
- domain_blacklist: 'domain3.com, *.domain4.com'
+ domain_denylist_enabled: true,
+ domain_denylist: 'domain3.com, *.domain4.com'
}
expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['domain_blacklist_enabled']).to be(true)
- expect(json_response['domain_blacklist']).to eq(['domain3.com', '*.domain4.com'])
+ expect(json_response['domain_denylist_enabled']).to be(true)
+ expect(json_response['domain_denylist']).to eq(['domain3.com', '*.domain4.com'])
end
end
@@ -423,6 +426,14 @@ RSpec.describe API::Settings, 'Settings' do
expect(json_response['abuse_notification_email']).to eq('test@example.com')
end
+ it 'supports setting require_admin_approval_after_user_signup' do
+ put api('/application/settings', admin),
+ params: { require_admin_approval_after_user_signup: true }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response['require_admin_approval_after_user_signup']).to eq(true)
+ end
+
context "missing sourcegraph_url value when sourcegraph_enabled is true" do
it "returns a blank parameter error message" do
put api("/application/settings", admin), params: { sourcegraph_enabled: true }
diff --git a/spec/requests/api/terraform/state_spec.rb b/spec/requests/api/terraform/state_spec.rb
index b91f6e1aa88..0fa088a641e 100644
--- a/spec/requests/api/terraform/state_spec.rb
+++ b/spec/requests/api/terraform/state_spec.rb
@@ -113,7 +113,7 @@ RSpec.describe API::Terraform::State do
end
describe 'POST /projects/:id/terraform/state/:name' do
- let(:params) { { 'instance': 'example-instance', 'serial': '1' } }
+ let(:params) { { 'instance': 'example-instance', 'serial': state.latest_version.version + 1 } }
subject(:request) { post api(state_path), headers: auth_header, as: :json, params: params }
@@ -202,6 +202,18 @@ RSpec.describe API::Terraform::State do
end
end
end
+
+ context 'when using job token authentication' do
+ let(:job) { create(:ci_build, status: :running, project: project, user: maintainer) }
+ let(:auth_header) { job_basic_auth_header(job) }
+
+ it 'associates the job with the newly created state version' do
+ expect { request }.to change { state.versions.count }.by(1)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(state.reload_latest_version.build).to eq(job)
+ end
+ end
end
describe 'DELETE /projects/:id/terraform/state/:name' do
diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb
index 7330c89fe77..98840d6238a 100644
--- a/spec/requests/api/users_spec.rb
+++ b/spec/requests/api/users_spec.rb
@@ -161,7 +161,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
context 'accesses the profile of another admin' do
- let(:admin_2) {create(:admin, note: '2010-10-10 | 2FA added | admin requested | www.gitlab.com')}
+ let(:admin_2) { create(:admin, note: '2010-10-10 | 2FA added | admin requested | www.gitlab.com') }
it 'contains the note of the user' do
get api("/user?private_token=#{admin_personal_access_token}&sudo=#{admin_2.id}")
@@ -772,11 +772,11 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
it "does not create user with invalid email" do
post api('/users', admin),
- params: {
- email: 'invalid email',
- password: 'password',
- name: 'test'
- }
+ params: {
+ email: 'invalid email',
+ password: 'password',
+ name: 'test'
+ }
expect(response).to have_gitlab_http_status(:bad_request)
end
@@ -811,14 +811,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
it 'returns 400 error if user does not validate' do
post api('/users', admin),
- params: {
- password: 'pass',
- email: 'test@example.com',
- username: 'test!',
- name: 'test',
- bio: 'g' * 256,
- projects_limit: -1
- }
+ params: {
+ password: 'pass',
+ email: 'test@example.com',
+ username: 'test!',
+ name: 'test',
+ bio: 'g' * 256,
+ projects_limit: -1
+ }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['password'])
.to eq(['is too short (minimum is 8 characters)'])
@@ -838,23 +838,23 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
context 'with existing user' do
before do
post api('/users', admin),
- params: {
- email: 'test@example.com',
- password: 'password',
- username: 'test',
- name: 'foo'
- }
+ params: {
+ email: 'test@example.com',
+ password: 'password',
+ username: 'test',
+ name: 'foo'
+ }
end
it 'returns 409 conflict error if user with same email exists' do
expect do
post api('/users', admin),
- params: {
- name: 'foo',
- email: 'test@example.com',
- password: 'password',
- username: 'foo'
- }
+ params: {
+ name: 'foo',
+ email: 'test@example.com',
+ password: 'password',
+ username: 'foo'
+ }
end.to change { User.count }.by(0)
expect(response).to have_gitlab_http_status(:conflict)
expect(json_response['message']).to eq('Email has already been taken')
@@ -863,12 +863,12 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
it 'returns 409 conflict error if same username exists' do
expect do
post api('/users', admin),
- params: {
- name: 'foo',
- email: 'foo@example.com',
- password: 'password',
- username: 'test'
- }
+ params: {
+ name: 'foo',
+ email: 'foo@example.com',
+ password: 'password',
+ username: 'test'
+ }
end.to change { User.count }.by(0)
expect(response).to have_gitlab_http_status(:conflict)
expect(json_response['message']).to eq('Username has already been taken')
@@ -877,12 +877,12 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
it 'returns 409 conflict error if same username exists (case insensitive)' do
expect do
post api('/users', admin),
- params: {
- name: 'foo',
- email: 'foo@example.com',
- password: 'password',
- username: 'TEST'
- }
+ params: {
+ name: 'foo',
+ email: 'foo@example.com',
+ password: 'password',
+ username: 'TEST'
+ }
end.to change { User.count }.by(0)
expect(response).to have_gitlab_http_status(:conflict)
expect(json_response['message']).to eq('Username has already been taken')
@@ -1185,14 +1185,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
it 'returns 400 error if user does not validate' do
put api("/users/#{user.id}", admin),
- params: {
- password: 'pass',
- email: 'test@example.com',
- username: 'test!',
- name: 'test',
- bio: 'g' * 256,
- projects_limit: -1
- }
+ params: {
+ password: 'pass',
+ email: 'test@example.com',
+ username: 'test!',
+ name: 'test',
+ bio: 'g' * 256,
+ projects_limit: -1
+ }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['message']['password'])
.to eq(['is too short (minimum is 8 characters)'])
@@ -1714,14 +1714,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
context "hard delete disabled" do
it "does not delete user" do
- perform_enqueued_jobs { delete api("/users/#{user.id}", admin)}
+ perform_enqueued_jobs { delete api("/users/#{user.id}", admin) }
expect(response).to have_gitlab_http_status(:conflict)
end
end
context "hard delete enabled" do
it "delete user and group", :sidekiq_might_not_need_inline do
- perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin)}
+ perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) }
expect(response).to have_gitlab_http_status(:no_content)
expect(Group.exists?(group.id)).to be_falsy
end
@@ -1993,7 +1993,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
delete api("/user/keys/#{key.id}", user)
expect(response).to have_gitlab_http_status(:no_content)
- end.to change { user.keys.count}.by(-1)
+ end.to change { user.keys.count }.by(-1)
end
it_behaves_like '412 response' do
@@ -2124,7 +2124,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
post api("/user/gpg_keys/#{gpg_key.id}/revoke", user)
expect(response).to have_gitlab_http_status(:accepted)
- end.to change { user.gpg_keys.count}.by(-1)
+ end.to change { user.gpg_keys.count }.by(-1)
end
it 'returns 404 if key ID not found' do
@@ -2157,7 +2157,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
delete api("/user/gpg_keys/#{gpg_key.id}", user)
expect(response).to have_gitlab_http_status(:no_content)
- end.to change { user.gpg_keys.count}.by(-1)
+ end.to change { user.gpg_keys.count }.by(-1)
end
it 'returns 404 if key ID not found' do
@@ -2279,7 +2279,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
delete api("/user/emails/#{email.id}", user)
expect(response).to have_gitlab_http_status(:no_content)
- end.to change { user.emails.count}.by(-1)
+ end.to change { user.emails.count }.by(-1)
end
it_behaves_like '412 response' do
@@ -2756,6 +2756,124 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do
end
end
+ describe 'POST /users/:user_id/personal_access_tokens' do
+ let(:name) { 'new pat' }
+ let(:expires_at) { 3.days.from_now.to_date.to_s }
+ let(:scopes) { %w(api read_user) }
+
+ context 'when feature flag is enabled' do
+ before do
+ stub_feature_flags(pat_creation_api_for_admin: true)
+ end
+
+ it 'returns error if required attributes are missing' do
+ post api("/users/#{user.id}/personal_access_tokens", admin)
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ expect(json_response['error']).to eq('name is missing, scopes is missing, scopes does not have a valid value')
+ end
+
+ it 'returns a 404 error if user not found' do
+ post api("/users/#{non_existing_record_id}/personal_access_tokens", admin),
+ params: {
+ name: name,
+ scopes: scopes,
+ expires_at: expires_at
+ }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 User Not Found')
+ end
+
+ it 'returns a 401 error when not authenticated' do
+ post api("/users/#{user.id}/personal_access_tokens"),
+ params: {
+ name: name,
+ scopes: scopes,
+ expires_at: expires_at
+ }
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ expect(json_response['message']).to eq('401 Unauthorized')
+ end
+
+ it 'returns a 403 error when authenticated as normal user' do
+ post api("/users/#{user.id}/personal_access_tokens", user),
+ params: {
+ name: name,
+ scopes: scopes,
+ expires_at: expires_at
+ }
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ expect(json_response['message']).to eq('403 Forbidden')
+ end
+
+ it 'creates a personal access token when authenticated as admin' do
+ post api("/users/#{user.id}/personal_access_tokens", admin),
+ params: {
+ name: name,
+ expires_at: expires_at,
+ scopes: scopes
+ }
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(json_response['name']).to eq(name)
+ expect(json_response['scopes']).to eq(scopes)
+ expect(json_response['expires_at']).to eq(expires_at)
+ expect(json_response['id']).to be_present
+ expect(json_response['created_at']).to be_present
+ expect(json_response['active']).to be_truthy
+ expect(json_response['revoked']).to be_falsey
+ expect(json_response['token']).to be_present
+ end
+
+ context 'when an error is thrown by the model' do
+ let!(:admin_personal_access_token) { create(:personal_access_token, user: admin) }
+ let(:error_message) { 'error message' }
+
+ before do
+ allow_next_instance_of(PersonalAccessToken) do |personal_access_token|
+ allow(personal_access_token).to receive_message_chain(:errors, :full_messages)
+ .and_return([error_message])
+
+ allow(personal_access_token).to receive(:save).and_return(false)
+ end
+ end
+
+ it 'returns the error' do
+ post api("/users/#{user.id}/personal_access_tokens", personal_access_token: admin_personal_access_token),
+ params: {
+ name: name,
+ expires_at: expires_at,
+ scopes: scopes
+ }
+
+ expect(response).to have_gitlab_http_status(:unprocessable_entity)
+ expect(json_response['message']).to eq(error_message)
+ end
+ end
+ end
+
+ context 'when feature flag is disabled' do
+ before do
+ stub_feature_flags(pat_creation_api_for_admin: false)
+ end
+
+ it 'returns a 404' do
+ post api("/users/#{user.id}/personal_access_tokens", admin),
+ params: {
+ name: name,
+ expires_at: expires_at,
+ scopes: scopes
+ }
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ expect(json_response['message']).to eq('404 Not Found')
+ end
+ end
+ end
+
describe 'GET /users/:user_id/impersonation_tokens' do
let_it_be(:active_personal_access_token) { create(:personal_access_token, user: user) }
let_it_be(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) }
diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb
index a3bfa7ea33c..dc735e3714d 100644
--- a/spec/requests/git_http_spec.rb
+++ b/spec/requests/git_http_spec.rb
@@ -433,7 +433,7 @@ RSpec.describe 'Git HTTP requests' do
let(:path) { "#{redirect.path}.git" }
it 'downloads get status 200 for redirects' do
- clone_get(path, {})
+ clone_get(path)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -465,7 +465,7 @@ RSpec.describe 'Git HTTP requests' do
path: "/#{path}/info/refs?service=git-upload-pack"
})
- clone_get(path, env)
+ clone_get(path, **env)
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -493,7 +493,7 @@ RSpec.describe 'Git HTTP requests' do
it "rejects pulls with 401 Unauthorized for unknown projects (no project existence information leak)" do
user.block
- download('doesnt/exist.git', env) do |response|
+ download('doesnt/exist.git', **env) do |response|
expect(response).to have_gitlab_http_status(:unauthorized)
end
end
@@ -693,7 +693,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'downloads get status 200' do
- clone_get(path, env)
+ clone_get(path, **env)
expect(response).to have_gitlab_http_status(:ok)
end
@@ -745,7 +745,7 @@ RSpec.describe 'Git HTTP requests' do
# We know for sure it is not an information leak since pulls using
# the build token must be allowed.
it "rejects pushes with 403 Forbidden" do
- push_get(path, env)
+ push_get(path, **env)
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq(git_access_error(:auth_upload))
@@ -754,7 +754,7 @@ RSpec.describe 'Git HTTP requests' do
# We are "authenticated" as CI using a valid token here. But we are
# not authorized to see any other project, so return "not found".
it "rejects pulls for other project with 404 Not Found" do
- clone_get("#{other_project.full_path}.git", env)
+ clone_get("#{other_project.full_path}.git", **env)
expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to eq(git_access_error(:project_not_found))
@@ -777,7 +777,7 @@ RSpec.describe 'Git HTTP requests' do
let(:project) { create(:project) }
it 'rejects pulls with 404 Not Found' do
- clone_get path, env
+ clone_get(path, **env)
expect(response).to have_gitlab_http_status(:not_found)
expect(response.body).to eq(git_access_error(:no_repo))
@@ -785,7 +785,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'rejects pushes with 403 Forbidden' do
- push_get path, env
+ push_get(path, **env)
expect(response).to have_gitlab_http_status(:forbidden)
expect(response.body).to eq(git_access_error(:auth_upload))
@@ -889,7 +889,7 @@ RSpec.describe 'Git HTTP requests' do
end
it "responds with status 200" do
- clone_get(path, env) do |response|
+ clone_get(path, **env) do |response|
expect(response).to have_gitlab_http_status(:ok)
end
end
@@ -913,7 +913,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'blocks git access when the user did not accept terms', :aggregate_failures do
- clone_get(path, env) do |response|
+ clone_get(path, **env) do |response|
expect(response).to have_gitlab_http_status(:forbidden)
end
@@ -932,7 +932,7 @@ RSpec.describe 'Git HTTP requests' do
end
it 'allows clones' do
- clone_get(path, env) do |response|
+ clone_get(path, **env) do |response|
expect(response).to have_gitlab_http_status(:ok)
end
end
diff --git a/spec/requests/lfs_http_spec.rb b/spec/requests/lfs_http_spec.rb
index 31bb0586e9f..48d125a37c3 100644
--- a/spec/requests/lfs_http_spec.rb
+++ b/spec/requests/lfs_http_spec.rb
@@ -9,18 +9,17 @@ RSpec.describe 'Git LFS API and storage' do
let_it_be(:project, reload: true) { create(:project, :repository) }
let_it_be(:other_project) { create(:project, :repository) }
let_it_be(:user) { create(:user) }
- let!(:lfs_object) { create(:lfs_object, :with_file) }
+ let(:lfs_object) { create(:lfs_object, :with_file) }
let(:headers) do
{
'Authorization' => authorization,
- 'X-Sendfile-Type' => sendfile
+ 'X-Sendfile-Type' => 'X-Sendfile'
}.compact
end
let(:include_workhorse_jwt_header) { true }
let(:authorization) { }
- let(:sendfile) { }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }
let(:sample_oid) { lfs_object.oid }
@@ -37,18 +36,6 @@ RSpec.describe 'Git LFS API and storage' do
stub_lfs_setting(enabled: lfs_enabled)
end
- describe 'when LFS is disabled' do
- let(:lfs_enabled) { false }
- let(:body) { upload_body(multiple_objects) }
- let(:authorization) { authorize_user }
-
- before do
- post_lfs_json batch_url(project), body, headers
- end
-
- it_behaves_like 'LFS http 501 response'
- end
-
context 'project specific LFS settings' do
let(:body) { upload_body(sample_object) }
let(:authorization) { authorize_user }
@@ -60,105 +47,36 @@ RSpec.describe 'Git LFS API and storage' do
subject
end
- context 'with LFS disabled globally' do
- let(:lfs_enabled) { false }
-
- describe 'LFS disabled in project' do
- let(:project_lfs_enabled) { false }
-
- context 'when uploading' do
- subject { post_lfs_json(batch_url(project), body, headers) }
-
- it_behaves_like 'LFS http 501 response'
- end
+ describe 'LFS disabled in project' do
+ let(:project_lfs_enabled) { false }
- context 'when downloading' do
- subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
+ context 'when uploading' do
+ subject { post_lfs_json(batch_url(project), body, headers) }
- it_behaves_like 'LFS http 501 response'
- end
+ it_behaves_like 'LFS http 404 response'
end
- describe 'LFS enabled in project' do
- let(:project_lfs_enabled) { true }
-
- context 'when uploading' do
- subject { post_lfs_json(batch_url(project), body, headers) }
-
- it_behaves_like 'LFS http 501 response'
- end
+ context 'when downloading' do
+ subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
- context 'when downloading' do
- subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
-
- it_behaves_like 'LFS http 501 response'
- end
+ it_behaves_like 'LFS http 404 response'
end
end
- context 'with LFS enabled globally' do
- describe 'LFS disabled in project' do
- let(:project_lfs_enabled) { false }
-
- context 'when uploading' do
- subject { post_lfs_json(batch_url(project), body, headers) }
-
- it_behaves_like 'LFS http 403 response'
- end
-
- context 'when downloading' do
- subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
-
- it_behaves_like 'LFS http 403 response'
- end
- end
-
- describe 'LFS enabled in project' do
- let(:project_lfs_enabled) { true }
-
- context 'when uploading' do
- subject { post_lfs_json(batch_url(project), body, headers) }
-
- it_behaves_like 'LFS http 200 response'
- end
+ describe 'LFS enabled in project' do
+ let(:project_lfs_enabled) { true }
- context 'when downloading' do
- subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
+ context 'when uploading' do
+ subject { post_lfs_json(batch_url(project), body, headers) }
- it_behaves_like 'LFS http 200 response'
- end
+ it_behaves_like 'LFS http 200 response'
end
- end
- end
- describe 'deprecated API' do
- let(:authorization) { authorize_user }
+ context 'when downloading' do
+ subject { get(objects_url(project, sample_oid), params: {}, headers: headers) }
- shared_examples 'deprecated request' do
- before do
- subject
+ it_behaves_like 'LFS http 200 blob response'
end
-
- it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 501 }
- let(:message) { 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.' }
- end
- end
-
- context 'when fetching LFS object using deprecated API' do
- subject { get(deprecated_objects_url(project, sample_oid), params: {}, headers: headers) }
-
- it_behaves_like 'deprecated request'
- end
-
- context 'when handling LFS request using deprecated API' do
- subject { post_lfs_json(deprecated_objects_url(project), nil, headers) }
-
- it_behaves_like 'deprecated request'
- end
-
- def deprecated_objects_url(project, oid = nil)
- File.join(["#{project.http_url_to_repo}/info/lfs/objects/", oid].compact)
end
end
@@ -167,196 +85,133 @@ RSpec.describe 'Git LFS API and storage' do
let(:before_get) { }
before do
+ project.lfs_objects << lfs_object
update_permissions
before_get
+
get objects_url(project, sample_oid), params: {}, headers: headers
end
- context 'and request comes from gitlab-workhorse' do
- context 'without user being authorized' do
- it_behaves_like 'LFS http 401 response'
- end
+ context 'when LFS uses object storage' do
+ let(:authorization) { authorize_user }
- context 'with required headers' do
- shared_examples 'responds with a file' do
- let(:sendfile) { 'X-Sendfile' }
+ let(:update_permissions) do
+ project.add_maintainer(user)
+ end
- it_behaves_like 'LFS http 200 response'
+ context 'when proxy download is enabled' do
+ let(:before_get) do
+ stub_lfs_object_storage(proxy_download: true)
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
- it 'responds with the file location' do
- expect(response.headers['Content-Type']).to eq('application/octet-stream')
- expect(response.headers['X-Sendfile']).to eq(lfs_object.file.path)
- end
+ it 'responds with the workhorse send-url' do
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:")
end
+ end
- context 'with user is authorized' do
- let(:authorization) { authorize_user }
+ context 'when proxy download is disabled' do
+ let(:before_get) do
+ stub_lfs_object_storage(proxy_download: false)
+ lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
+ end
- context 'and does not have project access' do
- let(:update_permissions) do
- project.lfs_objects << lfs_object
- end
+ it 'responds with redirect' do
+ expect(response).to have_gitlab_http_status(:found)
+ end
- it_behaves_like 'LFS http 404 response'
- end
+ it 'responds with the file location' do
+ expect(response.location).to include(lfs_object.reload.file.path)
+ end
+ end
+ end
- context 'and does have project access' do
- let(:update_permissions) do
- project.add_maintainer(user)
- project.lfs_objects << lfs_object
- end
+ context 'when deploy key is authorized' do
+ let(:key) { create(:deploy_key) }
+ let(:authorization) { authorize_deploy_key }
- it_behaves_like 'responds with a file'
+ let(:update_permissions) do
+ project.deploy_keys << key
+ end
- context 'when LFS uses object storage' do
- context 'when proxy download is enabled' do
- let(:before_get) do
- stub_lfs_object_storage(proxy_download: true)
- lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
- end
+ it_behaves_like 'LFS http 200 blob response'
+ end
- it_behaves_like 'LFS http 200 response'
+ context 'when using a user key (LFSToken)' do
+ let(:authorization) { authorize_user_key }
- it 'responds with the workhorse send-url' do
- expect(response.headers[Gitlab::Workhorse::SEND_DATA_HEADER]).to start_with("send-url:")
- end
- end
+ context 'when user allowed' do
+ let(:update_permissions) do
+ project.add_maintainer(user)
+ end
- context 'when proxy download is disabled' do
- let(:before_get) do
- stub_lfs_object_storage(proxy_download: false)
- lfs_object.file.migrate!(LfsObjectUploader::Store::REMOTE)
- end
+ it_behaves_like 'LFS http 200 blob response'
- it 'responds with redirect' do
- expect(response).to have_gitlab_http_status(:found)
- end
+ context 'when user password is expired' do
+ let(:user) { create(:user, password_expires_at: 1.minute.ago)}
- it 'responds with the file location' do
- expect(response.location).to include(lfs_object.reload.file.path)
- end
- end
- end
- end
+ it_behaves_like 'LFS http 401 response'
end
- context 'when deploy key is authorized' do
- let(:key) { create(:deploy_key) }
- let(:authorization) { authorize_deploy_key }
-
- let(:update_permissions) do
- project.deploy_keys << key
- project.lfs_objects << lfs_object
- end
+ context 'when user is blocked' do
+ let(:user) { create(:user, :blocked)}
- it_behaves_like 'responds with a file'
+ it_behaves_like 'LFS http 401 response'
end
+ end
- describe 'when using a user key (LFSToken)' do
- let(:authorization) { authorize_user_key }
-
- context 'when user allowed' do
- let(:update_permissions) do
- project.add_maintainer(user)
- project.lfs_objects << lfs_object
- end
+ context 'when user not allowed' do
+ it_behaves_like 'LFS http 404 response'
+ end
+ end
- it_behaves_like 'responds with a file'
+ context 'when build is authorized as' do
+ let(:authorization) { authorize_ci_project }
- context 'when user password is expired' do
- let(:user) { create(:user, password_expires_at: 1.minute.ago)}
+ shared_examples 'can download LFS only from own projects' do
+ context 'for owned project' do
+ let(:project) { create(:project, namespace: user.namespace) }
- it_behaves_like 'LFS http 401 response'
- end
+ it_behaves_like 'LFS http 200 blob response'
+ end
- context 'when user is blocked' do
- let(:user) { create(:user, :blocked)}
+ context 'for member of project' do
+ let(:pipeline) { create(:ci_empty_pipeline, project: project) }
- it_behaves_like 'LFS http 401 response'
- end
+ let(:update_permissions) do
+ project.add_reporter(user)
end
- context 'when user not allowed' do
- let(:update_permissions) do
- project.lfs_objects << lfs_object
- end
-
- it_behaves_like 'LFS http 404 response'
- end
+ it_behaves_like 'LFS http 200 blob response'
end
- context 'when build is authorized as' do
- let(:authorization) { authorize_ci_project }
-
- shared_examples 'can download LFS only from own projects' do
- context 'for owned project' do
- let(:project) { create(:project, namespace: user.namespace) }
-
- let(:update_permissions) do
- project.lfs_objects << lfs_object
- end
-
- it_behaves_like 'responds with a file'
- end
-
- context 'for member of project' do
- let(:pipeline) { create(:ci_empty_pipeline, project: project) }
-
- let(:update_permissions) do
- project.add_reporter(user)
- project.lfs_objects << lfs_object
- end
-
- it_behaves_like 'responds with a file'
- end
+ context 'for other project' do
+ let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
- context 'for other project' do
- let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
-
- let(:update_permissions) do
- project.lfs_objects << lfs_object
- end
-
- it 'rejects downloading code' do
- expect(response).to have_gitlab_http_status(other_project_status)
- end
- end
- end
-
- context 'administrator' do
- let(:user) { create(:admin) }
- let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
-
- it_behaves_like 'can download LFS only from own projects' do
- # We render 403, because administrator does have normally access
- let(:other_project_status) { 403 }
- end
+ it 'rejects downloading code' do
+ expect(response).to have_gitlab_http_status(:not_found)
end
+ end
+ end
- context 'regular user' do
- let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
+ context 'administrator' do
+ let(:user) { create(:admin) }
+ let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it_behaves_like 'can download LFS only from own projects' do
- # We render 404, to prevent data leakage about existence of the project
- let(:other_project_status) { 404 }
- end
- end
+ it_behaves_like 'can download LFS only from own projects'
+ end
- context 'does not have user' do
- let(:build) { create(:ci_build, :running, pipeline: pipeline) }
+ context 'regular user' do
+ let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it_behaves_like 'can download LFS only from own projects' do
- # We render 404, to prevent data leakage about existence of the project
- let(:other_project_status) { 404 }
- end
- end
- end
+ it_behaves_like 'can download LFS only from own projects'
end
- context 'without required headers' do
- let(:authorization) { authorize_user }
+ context 'does not have user' do
+ let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it_behaves_like 'LFS http 404 response'
+ it_behaves_like 'can download LFS only from own projects'
end
end
end
@@ -511,7 +366,7 @@ RSpec.describe 'Git LFS API and storage' do
let(:role) { :reporter }
end
- context 'when user does is not member of the project' do
+ context 'when user is not a member of the project' do
let(:update_user_permissions) { nil }
it_behaves_like 'LFS http 404 response'
@@ -520,7 +375,7 @@ RSpec.describe 'Git LFS API and storage' do
context 'when user does not have download access' do
let(:role) { :guest }
- it_behaves_like 'LFS http 403 response'
+ it_behaves_like 'LFS http 404 response'
end
context 'when user password is expired' do
@@ -591,7 +446,7 @@ RSpec.describe 'Git LFS API and storage' do
let(:pipeline) { create(:ci_empty_pipeline, project: other_project) }
it 'rejects downloading code' do
- expect(response).to have_gitlab_http_status(other_project_status)
+ expect(response).to have_gitlab_http_status(:not_found)
end
end
end
@@ -600,28 +455,19 @@ RSpec.describe 'Git LFS API and storage' do
let(:user) { create(:admin) }
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it_behaves_like 'can download LFS only from own projects', renew_authorization: true do
- # We render 403, because administrator does have normally access
- let(:other_project_status) { 403 }
- end
+ it_behaves_like 'can download LFS only from own projects', renew_authorization: true
end
context 'regular user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline, user: user) }
- it_behaves_like 'can download LFS only from own projects', renew_authorization: true do
- # We render 404, to prevent data leakage about existence of the project
- let(:other_project_status) { 404 }
- end
+ it_behaves_like 'can download LFS only from own projects', renew_authorization: true
end
context 'does not have user' do
let(:build) { create(:ci_build, :running, pipeline: pipeline) }
- it_behaves_like 'can download LFS only from own projects', renew_authorization: false do
- # We render 404, to prevent data leakage about existence of the project
- let(:other_project_status) { 404 }
- end
+ it_behaves_like 'can download LFS only from own projects', renew_authorization: false
end
end
@@ -919,11 +765,7 @@ RSpec.describe 'Git LFS API and storage' do
put_authorize
end
- it_behaves_like 'LFS http 200 response'
-
- it 'uses the gitlab-workhorse content type' do
- expect(response.media_type).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
- end
+ it_behaves_like 'LFS http 200 workhorse response'
end
shared_examples 'a local file' do
@@ -1142,7 +984,7 @@ RSpec.describe 'Git LFS API and storage' do
put_authorize
end
- it_behaves_like 'LFS http 404 response'
+ it_behaves_like 'LFS http 403 response'
end
end
@@ -1155,7 +997,7 @@ RSpec.describe 'Git LFS API and storage' do
put_authorize
end
- it_behaves_like 'LFS http 200 response'
+ it_behaves_like 'LFS http 200 workhorse response'
context 'when user password is expired' do
let(:user) { create(:user, password_expires_at: 1.minute.ago)}
@@ -1202,7 +1044,7 @@ RSpec.describe 'Git LFS API and storage' do
put_authorize
end
- it_behaves_like 'LFS http 200 response'
+ it_behaves_like 'LFS http 200 workhorse response'
it 'with location of LFS store and object details' do
expect(json_response['TempPath']).to eq(LfsObjectUploader.workhorse_local_upload_path)
@@ -1330,4 +1172,50 @@ RSpec.describe 'Git LFS API and storage' do
"#{sample_oid}012345678"
end
end
+
+ context 'with projects' do
+ it_behaves_like 'LFS http requests' do
+ let(:container) { project }
+ let(:authorize_guest) { project.add_guest(user) }
+ let(:authorize_download) { project.add_reporter(user) }
+ let(:authorize_upload) { project.add_developer(user) }
+ end
+ end
+
+ context 'with project wikis' do
+ it_behaves_like 'LFS http requests' do
+ let(:container) { create(:project_wiki, :empty_repo, project: project) }
+ let(:authorize_guest) { project.add_guest(user) }
+ let(:authorize_download) { project.add_reporter(user) }
+ let(:authorize_upload) { project.add_developer(user) }
+ end
+ end
+
+ context 'with snippets' do
+ # LFS is not supported on snippets, so we override the shared examples
+ # to expect 404 responses instead.
+ [
+ 'LFS http 200 response',
+ 'LFS http 200 blob response',
+ 'LFS http 403 response'
+ ].each do |examples|
+ shared_examples_for(examples) { it_behaves_like 'LFS http 404 response' }
+ end
+
+ context 'with project snippets' do
+ it_behaves_like 'LFS http requests' do
+ let(:container) { create(:project_snippet, :empty_repo, project: project) }
+ let(:authorize_guest) { project.add_guest(user) }
+ let(:authorize_download) { project.add_reporter(user) }
+ let(:authorize_upload) { project.add_developer(user) }
+ end
+ end
+
+ context 'with personal snippets' do
+ it_behaves_like 'LFS http requests' do
+ let(:container) { create(:personal_snippet, :empty_repo) }
+ let(:authorize_upload) { container.update!(author: user) }
+ end
+ end
+ end
end
diff --git a/spec/requests/lfs_locks_api_spec.rb b/spec/requests/lfs_locks_api_spec.rb
index 34e345cb1cf..0eb3cb4ca07 100644
--- a/spec/requests/lfs_locks_api_spec.rb
+++ b/spec/requests/lfs_locks_api_spec.rb
@@ -3,24 +3,38 @@
require 'spec_helper'
RSpec.describe 'Git LFS File Locking API' do
+ include LfsHttpHelpers
include WorkhorseHelpers
- let(:project) { create(:project) }
- let(:maintainer) { create(:user) }
- let(:developer) { create(:user) }
- let(:guest) { create(:user) }
- let(:path) { 'README.md' }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:maintainer) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:reporter) { create(:user) }
+ let_it_be(:guest) { create(:user) }
+ let_it_be(:path) { 'README.md' }
+
+ let(:user) { developer }
let(:headers) do
{
- 'Authorization' => authorization
+ 'Authorization' => authorize_user
}.compact
end
shared_examples 'unauthorized request' do
- context 'when user is not authorized' do
- let(:authorization) { authorize_user(guest) }
+ context 'when user does not have download permission' do
+ let(:user) { guest }
- it 'returns a forbidden 403 response' do
+ it 'returns a 404 response' do
+ post_lfs_json url, body, headers
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+
+ context 'when user does not have upload permission' do
+ let(:user) { reporter }
+
+ it 'returns a 403 response' do
post_lfs_json url, body, headers
expect(response).to have_gitlab_http_status(:forbidden)
@@ -31,15 +45,15 @@ RSpec.describe 'Git LFS File Locking API' do
before do
allow(Gitlab.config.lfs).to receive(:enabled).and_return(true)
- project.add_developer(maintainer)
+ project.add_maintainer(maintainer)
project.add_developer(developer)
+ project.add_reporter(reporter)
project.add_guest(guest)
end
describe 'Create File Lock endpoint' do
- let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
- let(:authorization) { authorize_user(developer) }
- let(:body) { { path: path } }
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
+ let(:body) { { path: path } }
include_examples 'unauthorized request'
@@ -76,8 +90,7 @@ RSpec.describe 'Git LFS File Locking API' do
end
describe 'Listing File Locks endpoint' do
- let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
- let(:authorization) { authorize_user(developer) }
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks" }
include_examples 'unauthorized request'
@@ -95,8 +108,7 @@ RSpec.describe 'Git LFS File Locking API' do
end
describe 'List File Locks for verification endpoint' do
- let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/verify" }
- let(:authorization) { authorize_user(developer) }
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/verify" }
include_examples 'unauthorized request'
@@ -116,9 +128,8 @@ RSpec.describe 'Git LFS File Locking API' do
end
describe 'Delete File Lock endpoint' do
- let!(:lock) { lock_file('README.md', developer) }
- let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/#{lock[:id]}/unlock" }
- let(:authorization) { authorize_user(developer) }
+ let!(:lock) { lock_file('README.md', developer) }
+ let(:url) { "#{project.http_url_to_repo}/info/lfs/locks/#{lock[:id]}/unlock" }
include_examples 'unauthorized request'
@@ -136,7 +147,7 @@ RSpec.describe 'Git LFS File Locking API' do
end
context 'when a maintainer uses force' do
- let(:authorization) { authorize_user(maintainer) }
+ let(:user) { maintainer }
it 'deletes the lock' do
project.add_maintainer(maintainer)
@@ -154,14 +165,6 @@ RSpec.describe 'Git LFS File Locking API' do
result[:lock]
end
- def authorize_user(user)
- ActionController::HttpAuthentication::Basic.encode_credentials(user.username, user.password)
- end
-
- def post_lfs_json(url, body = nil, headers = nil)
- post(url, params: body.try(:to_json), headers: (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE))
- end
-
def do_get(url, params = nil, headers = nil)
get(url, params: (params || {}), headers: (headers || {}).merge('Content-Type' => LfsRequest::CONTENT_TYPE))
end
diff --git a/spec/requests/projects/metrics/dashboards/builder_spec.rb b/spec/requests/projects/metrics/dashboards/builder_spec.rb
index e59ed591f63..002acca2135 100644
--- a/spec/requests/projects/metrics/dashboards/builder_spec.rb
+++ b/spec/requests/projects/metrics/dashboards/builder_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe 'Projects::Metrics::Dashboards::BuilderController' do
label: Legend Label
YML
end
+
let_it_be(:invalid_panel_yml) do
<<~YML
---
diff --git a/spec/requests/projects/noteable_notes_spec.rb b/spec/requests/projects/noteable_notes_spec.rb
new file mode 100644
index 00000000000..2bf1ffb2edc
--- /dev/null
+++ b/spec/requests/projects/noteable_notes_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project noteable notes' do
+ describe '#index' do
+ let_it_be(:merge_request) { create(:merge_request) }
+
+ let(:etag_store) { Gitlab::EtagCaching::Store.new }
+ let(:notes_path) { project_noteable_notes_path(project, target_type: merge_request.class.name.underscore, target_id: merge_request.id) }
+ let(:project) { merge_request.project }
+ let(:user) { project.owner }
+
+ let(:response_etag) { response.headers['ETag'] }
+ let(:stored_etag) { "W/\"#{etag_store.get(notes_path)}\"" }
+
+ before do
+ login_as(user)
+ end
+
+ it 'does not set a Gitlab::EtagCaching ETag if there is a note' do
+ create(:note_on_merge_request, noteable: merge_request, project: merge_request.project)
+
+ get notes_path
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ # Rack::ETag will set an etag based on the body digest, but that doesn't
+ # interfere with notes pagination
+ expect(response_etag).not_to eq(stored_etag)
+ end
+
+ it 'sets a Gitlab::EtagCaching ETag if there is no note' do
+ get notes_path
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response_etag).to eq(stored_etag)
+ end
+ end
+end
diff --git a/spec/requests/projects/releases_controller_spec.rb b/spec/requests/projects/releases_controller_spec.rb
new file mode 100644
index 00000000000..8e492125ace
--- /dev/null
+++ b/spec/requests/projects/releases_controller_spec.rb
@@ -0,0 +1,60 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Projects::ReleasesController' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ login_as(user)
+ end
+
+ # Added as a request spec because of https://gitlab.com/gitlab-org/gitlab/-/issues/232386
+ describe 'GET #downloads' do
+ context 'filepath redirection' do
+ let_it_be(:release) { create(:release, project: project, tag: 'v11.9.0-rc2' ) }
+ let!(:link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: filepath, url: 'https://aws.example.com/s3/project/bin/hello-darwin-amd64') }
+ let_it_be(:url) { "#{project_releases_path(project)}/#{release.tag}/downloads/bin/darwin-amd64" }
+
+ let(:subject) { get url }
+
+ context 'valid filepath' do
+ let(:filepath) { '/bin/darwin-amd64' }
+
+ it 'redirects to the asset direct link' do
+ subject
+
+ expect(response).to redirect_to('https://aws.example.com/s3/project/bin/hello-darwin-amd64')
+ end
+
+ it 'redirects with a status of 302' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:redirect)
+ end
+ end
+
+ context 'invalid filepath' do
+ let(:filepath) { '/binaries/win32' }
+
+ it 'is not found' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'invalid filepath' do
+ let(:invalid_filepath) { 'bin/darwin-amd64' }
+
+ let(:subject) { create(:release_link, name: 'linux-amd64 binaries', filepath: invalid_filepath, url: 'https://aws.example.com/s3/project/bin/hello-darwin-amd64') }
+
+ it 'cannot create an invalid filepath' do
+ expect { subject }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+ end
+end
diff --git a/spec/requests/rack_attack_global_spec.rb b/spec/requests/rack_attack_global_spec.rb
index 9fdafc06695..805ac5a9118 100644
--- a/spec/requests/rack_attack_global_spec.rb
+++ b/spec/requests/rack_attack_global_spec.rb
@@ -125,7 +125,8 @@ RSpec.describe 'Rack Attack global throttles' do
env: :throttle,
remote_ip: '127.0.0.1',
request_method: 'GET',
- path: '/users/sign_in'
+ path: '/users/sign_in',
+ matched: 'throttle_unauthenticated'
}
expect(Gitlab::AuthLogger).to receive(:error).with(arguments)
@@ -319,4 +320,62 @@ RSpec.describe 'Rack Attack global throttles' do
it_behaves_like 'rate-limited web authenticated requests'
end
end
+
+ describe 'throttle bypass header' do
+ let(:headers) { {} }
+ let(:bypass_header) { 'gitlab-bypass-rate-limiting' }
+
+ def do_request
+ get '/users/sign_in', headers: headers
+ end
+
+ before do
+ # Disabling protected paths throttle, otherwise requests to
+ # '/users/sign_in' are caught by this throttle.
+ settings_to_set[:throttle_protected_paths_enabled] = false
+
+ # Set low limits
+ settings_to_set[:throttle_unauthenticated_requests_per_period] = requests_per_period
+ settings_to_set[:throttle_unauthenticated_period_in_seconds] = period_in_seconds
+
+ stub_env('GITLAB_THROTTLE_BYPASS_HEADER', bypass_header)
+ settings_to_set[:throttle_unauthenticated_enabled] = true
+
+ stub_application_setting(settings_to_set)
+ end
+
+ shared_examples 'reject requests over the rate limit' do
+ it 'rejects requests over the rate limit' do
+ # At first, allow requests under the rate limit.
+ requests_per_period.times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ # the last straw
+ expect_rejection { do_request }
+ end
+ end
+
+ context 'without the bypass header set' do
+ it_behaves_like 'reject requests over the rate limit'
+ end
+
+ context 'with bypass header set to 1' do
+ let(:headers) { { bypass_header => '1' } }
+
+ it 'does not throttle' do
+ (1 + requests_per_period).times do
+ do_request
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+ end
+ end
+
+ context 'with bypass header set to some other value' do
+ let(:headers) { { bypass_header => 'some other value' } }
+
+ it_behaves_like 'reject requests over the rate limit'
+ end
+ end
end
diff --git a/spec/requests/robots_txt_spec.rb b/spec/requests/robots_txt_spec.rb
new file mode 100644
index 00000000000..a8be4093a71
--- /dev/null
+++ b/spec/requests/robots_txt_spec.rb
@@ -0,0 +1,93 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Robots.txt Requests', :aggregate_failures do
+ before do
+ Gitlab::Testing::RobotsBlockerMiddleware.block_requests!
+ end
+
+ after do
+ Gitlab::Testing::RobotsBlockerMiddleware.allow_requests!
+ end
+
+ it 'allows the requests' do
+ requests = [
+ '/users/sign_in',
+ '/namespace/subnamespace/design.gitlab.com',
+ '/users/foo/snippets',
+ '/users/foo/snippets/1'
+ ]
+
+ requests.each do |request|
+ get request
+
+ expect(response).not_to have_gitlab_http_status(:service_unavailable), "#{request} must be allowed"
+ end
+ end
+
+ it 'blocks the requests' do
+ requests = [
+ '/autocomplete/users',
+ '/autocomplete/projects',
+ '/search',
+ '/admin',
+ '/profile',
+ '/dashboard',
+ '/users',
+ '/users/foo',
+ '/help',
+ '/s/',
+ '/-/profile',
+ '/-/ide/project',
+ '/foo/bar/new',
+ '/foo/bar/edit',
+ '/foo/bar/raw',
+ '/groups/foo/analytics',
+ '/groups/foo/contribution_analytics',
+ '/groups/foo/group_members',
+ '/foo/bar/project.git',
+ '/foo/bar/archive/foo',
+ '/foo/bar/repository/archive',
+ '/foo/bar/activity',
+ '/foo/bar/blame',
+ '/foo/bar/commits',
+ '/foo/bar/commit',
+ '/foo/bar/compare',
+ '/foo/bar/network',
+ '/foo/bar/graphs',
+ '/foo/bar/merge_requests/1.patch',
+ '/foo/bar/merge_requests/1.diff',
+ '/foo/bar/merge_requests/1/diffs',
+ '/foo/bar/deploy_keys',
+ '/foo/bar/hooks',
+ '/foo/bar/services',
+ '/foo/bar/protected_branches',
+ '/foo/bar/uploads/foo',
+ '/foo/bar/project_members',
+ '/foo/bar/settings',
+ '/namespace/subnamespace/design.gitlab.com/settings',
+ '/foo/bar/-/import',
+ '/foo/bar/-/environments',
+ '/foo/bar/-/jobs',
+ '/foo/bar/-/requirements_management',
+ '/foo/bar/-/pipelines',
+ '/foo/bar/-/pipeline_schedules',
+ '/foo/bar/-/dependencies',
+ '/foo/bar/-/licenses',
+ '/foo/bar/-/metrics',
+ '/foo/bar/-/incidents',
+ '/foo/bar/-/value_stream_analytics',
+ '/foo/bar/-/analytics',
+ '/foo/bar/insights',
+ '/foo/bar/-/issues/123/realtime_changes',
+ '/groups/group/-/epics/123/realtime_changes'
+ ]
+
+ requests.each do |request|
+ get request
+
+ expect(response).to have_gitlab_http_status(:service_unavailable), "#{request} must be disallowed"
+ end
+ end
+end
diff --git a/spec/requests/search_controller_spec.rb b/spec/requests/search_controller_spec.rb
index 52bfc480313..93a2fecab74 100644
--- a/spec/requests/search_controller_spec.rb
+++ b/spec/requests/search_controller_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe SearchController, type: :request do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :public, :repository, :wiki_repo, name: 'awesome project', group: group) }
- before_all do
+ before do
login_as(user)
end
@@ -31,9 +31,11 @@ RSpec.describe SearchController, type: :request do
context 'for issues scope' do
let(:object) { :issue }
- let(:creation_args) { { project: project } }
- let(:params) { { search: '*', scope: 'issues' } }
- let(:threshold) { 0 }
+ let(:creation_args) { { project: project, title: 'foo' } }
+ let(:params) { { search: 'foo', scope: 'issues' } }
+ # there are 4 additional queries run for the logged in user:
+ # (1) geo_nodes, (1) users, (2) broadcast_messages
+ let(:threshold) { 4 }
it_behaves_like 'an efficient database result'
end
@@ -41,9 +43,11 @@ RSpec.describe SearchController, type: :request do
context 'for merge_request scope' do
let(:creation_traits) { [:unique_branches] }
let(:object) { :merge_request }
- let(:creation_args) { { source_project: project } }
- let(:params) { { search: '*', scope: 'merge_requests' } }
- let(:threshold) { 0 }
+ let(:creation_args) { { source_project: project, title: 'bar' } }
+ let(:params) { { search: 'bar', scope: 'merge_requests' } }
+ # there are 4 additional queries run for the logged in user:
+ # - (1) geo_nodes, (1) users, (2) broadcast_messages
+ let(:threshold) { 4 }
it_behaves_like 'an efficient database result'
end
@@ -51,16 +55,74 @@ RSpec.describe SearchController, type: :request do
context 'for project scope' do
let(:creation_traits) { [:public] }
let(:object) { :project }
- let(:creation_args) { {} }
- let(:params) { { search: '*', scope: 'projects' } }
+ let(:creation_args) { { name: 'project' } }
+ let(:params) { { search: 'project', scope: 'projects' } }
# some N+1 queries still exist
# each project requires 3 extra queries
# - one count for forks
# - one count for open MRs
# - one count for open Issues
- let(:threshold) { 9 }
+ # there are 4 additional queries run for the logged in user:
+ # (1) geo_nodes, (1) users, (2) broadcast_messages
+ let(:threshold) { 13 }
it_behaves_like 'an efficient database result'
end
+
+ context 'when searching by SHA' do
+ let(:sha) { '6d394385cf567f80a8fd85055db1ab4c5295806f' }
+
+ it 'finds a commit and redirects to its page' do
+ send_search_request({ search: sha, scope: 'projects', project_id: project.id })
+
+ expect(response).to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'finds a commit in uppercase and redirects to its page' do
+ send_search_request( { search: sha.upcase, scope: 'projects', project_id: project.id })
+
+ expect(response).to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'finds a commit with a partial sha and redirects to its page' do
+ send_search_request( { search: sha[0..10], scope: 'projects', project_id: project.id })
+
+ expect(response).to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'redirects to the commit even if another scope result is returned' do
+ create(:note, project: project, note: "This is the #{sha}")
+ send_search_request( { search: sha, scope: 'projects', project_id: project.id })
+
+ expect(response).to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'goes to search results with the force_search_results param set' do
+ send_search_request({ search: sha, force_search_results: true, project_id: project.id })
+
+ expect(response).not_to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'does not redirect if user cannot download_code from project' do
+ allow(Ability).to receive(:allowed?).and_call_original
+ allow(Ability).to receive(:allowed?).with(user, :download_code, project).and_return(false)
+
+ send_search_request({ search: sha, project_id: project.id })
+
+ expect(response).not_to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'does not redirect if commit sha not found in project' do
+ send_search_request({ search: '23594bc765e25c5b22c17a8cca25ebd50f792598', project_id: project.id })
+
+ expect(response).not_to redirect_to(project_commit_path(project, sha))
+ end
+
+ it 'does not redirect if not using project scope' do
+ send_search_request({ search: sha, group_id: project.root_namespace.id })
+
+ expect(response).not_to redirect_to(project_commit_path(project, sha))
+ end
+ end
end
end
diff --git a/spec/requests/user_sends_malformed_strings_spec.rb b/spec/requests/user_sends_malformed_strings_spec.rb
new file mode 100644
index 00000000000..da533606be5
--- /dev/null
+++ b/spec/requests/user_sends_malformed_strings_spec.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User sends malformed strings' do
+ include GitHttpHelpers
+
+ let(:null_byte) { "\u0000" }
+ let(:invalid_string) { "mal\xC0formed" }
+
+ it 'raises a 400 error with a null byte' do
+ post '/nonexistent', params: { a: "A #{null_byte} nasty string" }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'raises a 400 error with an invalid string' do
+ post '/nonexistent', params: { a: "A #{invalid_string} nasty string" }
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+
+ it 'raises a 400 error with null bytes in the auth headers' do
+ clone_get("project/path", user: "hello#{null_byte}", password: "nothing to see")
+
+ expect(response).to have_gitlab_http_status(:bad_request)
+ end
+end
diff --git a/spec/requests/user_sends_null_bytes_spec.rb b/spec/requests/user_sends_null_bytes_spec.rb
deleted file mode 100644
index 1ddfad40996..00000000000
--- a/spec/requests/user_sends_null_bytes_spec.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'User sends null bytes as params' do
- let(:null_byte) { "\u0000" }
-
- it 'raises a 400 error' do
- post '/nonexistent', params: { a: "A #{null_byte} nasty string" }
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(response.body).to eq('Bad Request')
- end
-end
diff --git a/spec/requests/whats_new_controller_spec.rb b/spec/requests/whats_new_controller_spec.rb
index 29500a7b5f9..c04a6b00a93 100644
--- a/spec/requests/whats_new_controller_spec.rb
+++ b/spec/requests/whats_new_controller_spec.rb
@@ -4,19 +4,44 @@ require 'spec_helper'
RSpec.describe WhatsNewController do
describe 'whats_new_path' do
- before do
- allow_any_instance_of(WhatsNewController).to receive(:whats_new_most_recent_release_items).and_return('items')
- end
-
context 'with whats_new_drawer feature enabled' do
+ let(:fixture_dir_glob) { Dir.glob(File.join('spec', 'fixtures', 'whats_new', '*.yml')) }
+
before do
stub_feature_flags(whats_new_drawer: true)
+ allow(Dir).to receive(:glob).with(Rails.root.join('data', 'whats_new', '*.yml')).and_return(fixture_dir_glob)
end
- it 'is successful' do
- get whats_new_path, xhr: true
+ context 'with no page param' do
+ it 'responds with paginated data and headers' do
+ get whats_new_path, xhr: true
+
+ expect(response.body).to eq([{ title: "bright and sunshinin' day", release: "01.05" }].to_json)
+ expect(response.headers['X-Next-Page']).to eq(2)
+ end
+ end
+
+ context 'with page param' do
+ it 'responds with paginated data and headers' do
+ get whats_new_path(page: 2), xhr: true
+
+ expect(response.body).to eq([{ title: 'bright' }].to_json)
+ expect(response.headers['X-Next-Page']).to eq(3)
+ end
+
+ it 'returns a 404 if page param is negative' do
+ get whats_new_path(page: -1), xhr: true
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
- expect(response).to have_gitlab_http_status(:ok)
+ context 'when there are no more paginated results' do
+ it 'responds with nil X-Next-Page header' do
+ get whats_new_path(page: 3), xhr: true
+ expect(response.body).to eq([{ title: "It's gonna be a bright" }].to_json)
+ expect(response.headers['X-Next-Page']).to be nil
+ end
+ end
end
end
diff --git a/spec/routing/group_routing_spec.rb b/spec/routing/group_routing_spec.rb
index 9de99b73d23..f4d5ccc81b6 100644
--- a/spec/routing/group_routing_spec.rb
+++ b/spec/routing/group_routing_spec.rb
@@ -79,4 +79,40 @@ RSpec.describe "Groups", "routing" do
let(:group_path) { 'projects.abc123' }
end
end
+
+ describe 'dependency proxy for containers' do
+ context 'image name without namespace' do
+ it 'routes to #manifest' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/manifests/2.3.6'))
+ .to route_to('groups/dependency_proxy_for_containers#manifest', group_id: 'gitlabhq', image: 'ruby', tag: '2.3.6')
+ end
+
+ it 'routes to #blob' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/blobs/abc12345'))
+ .to route_to('groups/dependency_proxy_for_containers#blob', group_id: 'gitlabhq', image: 'ruby', sha: 'abc12345')
+ end
+
+ it 'does not route to #blob with an invalid sha' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/ruby/blobs/sha256:asdf1234%2f%2e%2e'))
+ .not_to route_to(group_id: 'gitlabhq', image: 'ruby', sha: 'sha256:asdf1234%2f%2e%2e')
+ end
+
+ it 'does not route to #blob with an invalid image' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/ru*by/blobs/abc12345'))
+ .not_to route_to('groups/dependency_proxy_for_containers#blob', group_id: 'gitlabhq', image: 'ru*by', sha: 'abc12345')
+ end
+ end
+
+ context 'image name with namespace' do
+ it 'routes to #manifest' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/foo/bar/manifests/2.3.6'))
+ .to route_to('groups/dependency_proxy_for_containers#manifest', group_id: 'gitlabhq', image: 'foo/bar', tag: '2.3.6')
+ end
+
+ it 'routes to #blob' do
+ expect(get('/v2/gitlabhq/dependency_proxy/containers/foo/bar/blobs/abc12345'))
+ .to route_to('groups/dependency_proxy_for_containers#blob', group_id: 'gitlabhq', image: 'foo/bar', sha: 'abc12345')
+ end
+ end
+ end
end
diff --git a/spec/routing/openid_connect_spec.rb b/spec/routing/openid_connect_spec.rb
index b5291953730..dc9190114fd 100644
--- a/spec/routing/openid_connect_spec.rb
+++ b/spec/routing/openid_connect_spec.rb
@@ -3,7 +3,6 @@
require 'spec_helper'
# oauth_discovery_keys GET /oauth/discovery/keys(.:format) doorkeeper/openid_connect/discovery#keys
-# jwks GET /-/jwks(.:format) doorkeeper/openid_connect/discovery#keys
# oauth_discovery_provider GET /.well-known/openid-configuration(.:format) doorkeeper/openid_connect/discovery#provider
# oauth_discovery_webfinger GET /.well-known/webfinger(.:format) doorkeeper/openid_connect/discovery#webfinger
RSpec.describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do
@@ -18,10 +17,6 @@ RSpec.describe Doorkeeper::OpenidConnect::DiscoveryController, 'routing' do
it "to #keys" do
expect(get('/oauth/discovery/keys')).to route_to('doorkeeper/openid_connect/discovery#keys')
end
-
- it "/-/jwks" do
- expect(get('/-/jwks')).to route_to('doorkeeper/openid_connect/discovery#keys')
- end
end
# oauth_userinfo GET /oauth/userinfo(.:format) doorkeeper/openid_connect/userinfo#show
diff --git a/spec/routing/routing_spec.rb b/spec/routing/routing_spec.rb
index f665dc31ee4..76ccdf3237c 100644
--- a/spec/routing/routing_spec.rb
+++ b/spec/routing/routing_spec.rb
@@ -369,3 +369,10 @@ RSpec.describe RunnerSetupController, 'routing' do
expect(get("/-/runner_setup/platforms")).to route_to('runner_setup#platforms')
end
end
+
+# jwks GET /-/jwks(.:format) jwks#index
+RSpec.describe JwksController, "routing" do
+ it "to #index" do
+ expect(get('/-/jwks')).to route_to('jwks#index')
+ end
+end
diff --git a/spec/rubocop/cop/code_reuse/active_record_spec.rb b/spec/rubocop/cop/code_reuse/active_record_spec.rb
deleted file mode 100644
index e15b9e11aed..00000000000
--- a/spec/rubocop/cop/code_reuse/active_record_spec.rb
+++ /dev/null
@@ -1,134 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-require 'rubocop'
-require_relative '../../../../rubocop/cop/code_reuse/active_record'
-
-RSpec.describe RuboCop::Cop::CodeReuse::ActiveRecord, type: :rubocop do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- it 'flags the use of "where" without any arguments' do
- expect_offense(<<~SOURCE)
- def foo
- User.where
- ^^^^^ This method can only be used inside an ActiveRecord model: https://gitlab.com/gitlab-org/gitlab-foss/issues/49653
- end
- SOURCE
- end
-
- it 'flags the use of "where" with arguments' do
- expect_offense(<<~SOURCE)
- def foo
- User.where(id: 10)
- ^^^^^ This method can only be used inside an ActiveRecord model: https://gitlab.com/gitlab-org/gitlab-foss/issues/49653
- end
- SOURCE
- end
-
- it 'does not flag the use of "group" without any arguments' do
- expect_no_offenses(<<~SOURCE)
- def foo
- project.group
- end
- SOURCE
- end
-
- it 'flags the use of "group" with arguments' do
- expect_offense(<<~SOURCE)
- def foo
- project.group(:name)
- ^^^^^ This method can only be used inside an ActiveRecord model: https://gitlab.com/gitlab-org/gitlab-foss/issues/49653
- end
- SOURCE
- end
-
- it 'does not flag the use of ActiveRecord models in a model' do
- path = rails_root_join('app', 'models', 'foo.rb').to_s
-
- expect_no_offenses(<<~SOURCE, path)
- def foo
- project.group(:name)
- end
- SOURCE
- end
-
- it 'does not flag the use of ActiveRecord models in a spec' do
- path = rails_root_join('spec', 'foo_spec.rb').to_s
-
- expect_no_offenses(<<~SOURCE, path)
- def foo
- project.group(:name)
- end
- SOURCE
- end
-
- it 'does not flag the use of ActiveRecord models in a background migration' do
- path = rails_root_join('lib', 'gitlab', 'background_migration', 'foo.rb').to_s
-
- expect_no_offenses(<<~SOURCE, path)
- def foo
- project.group(:name)
- end
- SOURCE
- end
-
- it 'does not flag the use of ActiveRecord models in lib/gitlab/database' do
- path = rails_root_join('lib', 'gitlab', 'database', 'foo.rb').to_s
-
- expect_no_offenses(<<~SOURCE, path)
- def foo
- project.group(:name)
- end
- SOURCE
- end
-
- it 'autocorrects offenses in instance methods by allowing them' do
- corrected = autocorrect_source(<<~SOURCE)
- def foo
- User.where
- end
- SOURCE
-
- expect(corrected).to eq(<<~SOURCE)
- # rubocop: disable CodeReuse/ActiveRecord
- def foo
- User.where
- end
- # rubocop: enable CodeReuse/ActiveRecord
- SOURCE
- end
-
- it 'autocorrects offenses in class methods by allowing them' do
- corrected = autocorrect_source(<<~SOURCE)
- def self.foo
- User.where
- end
- SOURCE
-
- expect(corrected).to eq(<<~SOURCE)
- # rubocop: disable CodeReuse/ActiveRecord
- def self.foo
- User.where
- end
- # rubocop: enable CodeReuse/ActiveRecord
- SOURCE
- end
-
- it 'autocorrects offenses in blocks by allowing them' do
- corrected = autocorrect_source(<<~SOURCE)
- get '/' do
- User.where
- end
- SOURCE
-
- expect(corrected).to eq(<<~SOURCE)
- # rubocop: disable CodeReuse/ActiveRecord
- get '/' do
- User.where
- end
- # rubocop: enable CodeReuse/ActiveRecord
- SOURCE
- end
-end
diff --git a/spec/rubocop/cop/graphql/resolver_type_spec.rb b/spec/rubocop/cop/graphql/resolver_type_spec.rb
new file mode 100644
index 00000000000..4807d66396a
--- /dev/null
+++ b/spec/rubocop/cop/graphql/resolver_type_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'fast_spec_helper'
+require 'rubocop'
+
+require_relative '../../../../rubocop/cop/graphql/resolver_type'
+
+RSpec.describe RuboCop::Cop::Graphql::ResolverType, type: :rubocop do
+ include CopHelper
+
+ subject(:cop) { described_class.new }
+
+ it 'adds an offense when there is no type annotaion' do
+ lacks_type = <<-SRC
+ module Resolvers
+ class FooResolver < BaseResolver
+ def resolve(**args)
+ [:thing]
+ end
+ end
+ end
+ SRC
+
+ inspect_source(lacks_type)
+
+ expect(cop.offenses.size).to eq 1
+ end
+
+ it 'does not add an offense for resolvers that have a type call' do
+ expect_no_offenses(<<-SRC)
+ module Resolvers
+ class FooResolver < BaseResolver
+ type [SomeEnum], null: true
+
+ def resolve(**args)
+ [:thing]
+ end
+ end
+ end
+ SRC
+ end
+
+ it 'ignores type calls on other objects' do
+ lacks_type = <<-SRC
+ module Resolvers
+ class FooResolver < BaseResolver
+ class FalsePositive < BaseObject
+ type RedHerringType, null: true
+ end
+
+ def resolve(**args)
+ [:thing]
+ end
+ end
+ end
+ SRC
+
+ inspect_source(lacks_type)
+
+ expect(cop.offenses.size).to eq 1
+ end
+
+ it 'does not add an offense unless the class is named using the Resolver convention' do
+ expect_no_offenses(<<-TYPE)
+ module Resolvers
+ class FooThingy
+ def something_other_than_resolve(**args)
+ [:thing]
+ end
+ end
+ end
+ TYPE
+ end
+end
diff --git a/spec/rubocop/cop/line_break_around_conditional_block_spec.rb b/spec/rubocop/cop/line_break_around_conditional_block_spec.rb
deleted file mode 100644
index 0a26ef49e35..00000000000
--- a/spec/rubocop/cop/line_break_around_conditional_block_spec.rb
+++ /dev/null
@@ -1,454 +0,0 @@
-# frozen_string_literal: true
-
-require 'fast_spec_helper'
-require 'rubocop'
-require 'rubocop/rspec/support'
-require_relative '../../../rubocop/cop/line_break_around_conditional_block'
-
-RSpec.describe RuboCop::Cop::LineBreakAroundConditionalBlock, type: :rubocop do
- include CopHelper
-
- subject(:cop) { described_class.new }
-
- shared_examples 'examples with conditional' do |conditional|
- it "flags violation for #{conditional} without line break before" do
- source = <<~RUBY
- do_something
- #{conditional} condition
- do_something_more
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(1)
- offense = cop.offenses.first
-
- expect(offense.line).to eq(2)
- expect(cop.highlights).to eq(["#{conditional} condition\n do_something_more\nend"])
- expect(offense.message).to eq('Add a line break around conditional blocks')
- end
-
- it "flags violation for #{conditional} without line break after" do
- source = <<~RUBY
- #{conditional} condition
- do_something
- end
- do_something_more
- RUBY
- inspect_source(source)
-
- expect(cop.offenses.size).to eq(1)
- offense = cop.offenses.first
-
- expect(offense.line).to eq(1)
- expect(cop.highlights).to eq(["#{conditional} condition\n do_something\nend"])
- expect(offense.message).to eq('Add a line break around conditional blocks')
- end
-
- it "doesn't flag violation for #{conditional} with line break before and after" do
- source = <<~RUBY
- #{conditional} condition
- do_something
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a method definition" do
- source = <<~RUBY
- def a_method
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a class definition" do
- source = <<~RUBY
- class Foo
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a module definition" do
- source = <<~RUBY
- module Foo
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a begin definition" do
- source = <<~RUBY
- begin
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by an assign/begin definition" do
- source = <<~RUBY
- @project ||= begin
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a block definition" do
- source = <<~RUBY
- on_block(param_a) do |item|
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a block definition with a comment" do
- source = <<~RUBY
- on_block(param_a) do |item| # a short comment
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a block definition using brackets" do
- source = <<~RUBY
- on_block(param_a) { |item|
- #{conditional} condition
- do_something
- end
- }
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a comment" do
- source = <<~RUBY
- # a short comment
- #{conditional} condition
- do_something
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by an assignment" do
- source = <<~RUBY
- foo =
- #{conditional} condition
- do_something
- else
- do_something_more
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a multiline comment" do
- source = <<~RUBY
- =begin
- a multiline comment
- =end
- #{conditional} condition
- do_something
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by another conditional" do
- source = <<~RUBY
- #{conditional} condition_a
- #{conditional} condition_b
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by an else" do
- source = <<~RUBY
- if condition_a
- do_something
- else
- #{conditional} condition_b
- do_something_extra
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by an elsif" do
- source = <<~RUBY
- if condition_a
- do_something
- elsif condition_b
- #{conditional} condition_c
- do_something_extra
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by an ensure" do
- source = <<~RUBY
- def a_method
- ensure
- #{conditional} condition_c
- do_something_extra
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a when" do
- source = <<~RUBY
- case field
- when value
- #{conditional} condition_c
- do_something_extra
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by a comment" do
- source = <<~RUBY
- #{conditional} condition
- do_something
- end
- # a short comment
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by an end" do
- source = <<~RUBY
- class Foo
-
- #{conditional} condition
- do_something
- end
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by an else" do
- source = <<~RUBY
- #{conditional} condition_a
- #{conditional} condition_b
- do_something
- end
- else
- do_something_extra
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by a when" do
- source = <<~RUBY
- case
- when condition_a
- #{conditional} condition_b
- do_something
- end
- when condition_c
- do_something_extra
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by an elsif" do
- source = <<~RUBY
- if condition_a
- #{conditional} condition_b
- do_something
- end
- elsif condition_c
- do_something_extra
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} preceded by a rescue" do
- source = <<~RUBY
- def a_method
- do_something
- rescue
- #{conditional} condition
- do_something
- end
- end
- RUBY
-
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "doesn't flag violation for #{conditional} followed by a rescue" do
- source = <<~RUBY
- def a_method
- #{conditional} condition
- do_something
- end
- rescue
- do_something_extra
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-
- it "autocorrects #{conditional} without line break before" do
- source = <<~RUBY
- do_something
- #{conditional} condition
- do_something_more
- end
- RUBY
- autocorrected = autocorrect_source(source)
-
- expected_source = <<~RUBY
- do_something
-
- #{conditional} condition
- do_something_more
- end
- RUBY
- expect(autocorrected).to eql(expected_source)
- end
-
- it "autocorrects #{conditional} without line break after" do
- source = <<~RUBY
- #{conditional} condition
- do_something
- end
- do_something_more
- RUBY
- autocorrected = autocorrect_source(source)
-
- expected_source = <<~RUBY
- #{conditional} condition
- do_something
- end
-
- do_something_more
- RUBY
- expect(autocorrected).to eql(expected_source)
- end
-
- it "autocorrects #{conditional} without line break before and after" do
- source = <<~RUBY
- do_something
- #{conditional} condition
- do_something_more
- end
- do_something_extra
- RUBY
- autocorrected = autocorrect_source(source)
-
- expected_source = <<~RUBY
- do_something
-
- #{conditional} condition
- do_something_more
- end
-
- do_something_extra
- RUBY
- expect(autocorrected).to eql(expected_source)
- end
- end
-
- %w[if unless].each do |example|
- it_behaves_like 'examples with conditional', example
- end
-
- it "doesn't flag violation for if with elsif" do
- source = <<~RUBY
- if condition
- do_something
- elsif another_condition
- do_something_more
- end
- RUBY
- inspect_source(source)
-
- expect(cop.offenses).to be_empty
- end
-end
diff --git a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
index a16cd8b634f..b14cf39cbde 100644
--- a/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
+++ b/spec/rubocop/cop/rspec/be_success_matcher_spec.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
require 'fast_spec_helper'
-
+require 'rubocop'
require_relative '../../../../rubocop/cop/rspec/be_success_matcher'
RSpec.describe RuboCop::Cop::RSpec::BeSuccessMatcher, type: :rubocop do
diff --git a/spec/serializers/base_discussion_entity_spec.rb b/spec/serializers/base_discussion_entity_spec.rb
new file mode 100644
index 00000000000..5f483da4113
--- /dev/null
+++ b/spec/serializers/base_discussion_entity_spec.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BaseDiscussionEntity do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:note) { create(:discussion_note_on_merge_request) }
+
+ let(:request) { double('request', note_entity: ProjectNoteEntity) }
+ let(:controller) { double('controller') }
+ let(:entity) { described_class.new(discussion, request: request, context: controller) }
+ let(:discussion) { note.discussion }
+
+ subject { entity.as_json }
+
+ before do
+ allow(controller).to receive(:render_to_string)
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:noteable).and_return(note.noteable)
+ end
+
+ it 'exposes correct attributes' do
+ expect(subject.keys.sort).to include(
+ :commit_id,
+ :confidential,
+ :diff_discussion,
+ :discussion_path,
+ :expanded,
+ :for_commit,
+ :id,
+ :individual_note,
+ :resolvable,
+ :resolve_path,
+ :resolve_with_issue_path
+ )
+ end
+
+ context 'when is LegacyDiffDiscussion' do
+ let(:project) { create(:project) }
+ let(:merge_request) { create(:merge_request, source_project: project) }
+ let(:discussion) { create(:legacy_diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
+
+ it 'exposes correct attributes' do
+ expect(subject.keys.sort).to include(
+ :commit_id,
+ :diff_discussion,
+ :discussion_path,
+ :expanded,
+ :for_commit,
+ :id,
+ :individual_note
+ )
+ end
+ end
+
+ context 'when diff file is present' do
+ let(:note) { create(:diff_note_on_merge_request) }
+
+ it 'exposes diff file attributes' do
+ expect(subject.keys.sort).to include(
+ :active,
+ :diff_file,
+ :line_code,
+ :position,
+ :truncated_diff_lines
+ )
+ end
+ end
+end
diff --git a/spec/serializers/diff_file_entity_spec.rb b/spec/serializers/diff_file_entity_spec.rb
index bebe2e2dfb5..1b8456e5c49 100644
--- a/spec/serializers/diff_file_entity_spec.rb
+++ b/spec/serializers/diff_file_entity_spec.rb
@@ -69,4 +69,15 @@ RSpec.describe DiffFileEntity do
end
end
end
+
+ describe '#is_fully_expanded' do
+ context 'file with a conflict' do
+ let(:options) { { conflicts: { diff_file.new_path => double(diff_lines_for_serializer: []) } } }
+
+ it 'returns false' do
+ expect(diff_file).not_to receive(:fully_expanded?)
+ expect(subject[:is_fully_expanded]).to eq(false)
+ end
+ end
+ end
end
diff --git a/spec/serializers/diffs_entity_spec.rb b/spec/serializers/diffs_entity_spec.rb
index 5928a1c24b3..7569493573b 100644
--- a/spec/serializers/diffs_entity_spec.rb
+++ b/spec/serializers/diffs_entity_spec.rb
@@ -8,9 +8,12 @@ RSpec.describe DiffsEntity do
let(:request) { EntityRequest.new(project: project, current_user: user) }
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
let(:merge_request_diffs) { merge_request.merge_request_diffs }
+ let(:options) do
+ { request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs }
+ end
let(:entity) do
- described_class.new(merge_request_diffs.first.diffs, request: request, merge_request: merge_request, merge_request_diffs: merge_request_diffs)
+ described_class.new(merge_request_diffs.first.diffs, options)
end
context 'as json' do
@@ -68,5 +71,50 @@ RSpec.describe DiffsEntity do
end
end
end
+
+ context 'when there are conflicts' do
+ let(:diff_files) { merge_request_diffs.first.diffs.diff_files }
+ let(:diff_file_with_conflict) { diff_files.to_a.last }
+ let(:diff_file_without_conflict) { diff_files.to_a[-2] }
+
+ let(:resolvable_conflicts) { true }
+ let(:conflict_file) { double(our_path: diff_file_with_conflict.new_path) }
+ let(:conflicts) { double(conflicts: double(files: [conflict_file]), can_be_resolved_in_ui?: resolvable_conflicts) }
+
+ let(:merge_ref_head_diff) { true }
+ let(:options) { super().merge(merge_ref_head_diff: merge_ref_head_diff) }
+
+ before do
+ allow(MergeRequests::Conflicts::ListService).to receive(:new).and_return(conflicts)
+ end
+
+ it 'conflicts are highlighted' do
+ expect(conflict_file).to receive(:diff_lines_for_serializer)
+ expect(diff_file_with_conflict).not_to receive(:diff_lines_for_serializer)
+ expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+
+ subject
+ end
+
+ context 'merge ref head diff is not chosen to be displayed' do
+ let(:merge_ref_head_diff) { false }
+
+ it 'conflicts are not calculated' do
+ expect(MergeRequests::Conflicts::ListService).not_to receive(:new)
+ end
+ end
+
+ context 'when conflicts cannot be resolved' do
+ let(:resolvable_conflicts) { false }
+
+ it 'conflicts are not highlighted' do
+ expect(conflict_file).not_to receive(:diff_lines_for_serializer)
+ expect(diff_file_with_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+ expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+
+ subject
+ end
+ end
+ end
end
end
diff --git a/spec/serializers/discussion_entity_spec.rb b/spec/serializers/discussion_entity_spec.rb
index e1734d5290f..0645d19da5b 100644
--- a/spec/serializers/discussion_entity_spec.rb
+++ b/spec/serializers/discussion_entity_spec.rb
@@ -39,6 +39,10 @@ RSpec.describe DiscussionEntity do
)
end
+ it 'does not include base discussion in the notes' do
+ expect(subject[:notes].first.keys).not_to include(:base_discussion)
+ end
+
it 'resolved_by matches note_user_entity schema' do
Notes::ResolveService.new(note.project, user).execute(note)
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index c90f771335e..f5d6706a844 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe EnvironmentEntity do
end
it 'exposes core elements of environment' do
- expect(subject).to include(:id, :name, :state, :environment_path)
+ expect(subject).to include(:id, :global_id, :name, :state, :environment_path)
end
it 'exposes folder path' do
diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb
index 5cad35eaedf..3f7d5542ae8 100644
--- a/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/spec/serializers/merge_request_widget_entity_spec.rb
@@ -106,29 +106,16 @@ RSpec.describe MergeRequestWidgetEntity do
let(:merge_base_job_id) { merge_base_pipeline.builds.first.id }
it 'has head_path and base_path entries' do
- expect(subject[:codeclimate][:head_path]).to be_present
- expect(subject[:codeclimate][:base_path]).to be_present
+ expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
+ expect(subject[:codeclimate][:base_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
end
context 'on pipelines for merged results' do
let(:pipeline) { create(:ci_pipeline, :merged_result_pipeline, :with_codequality_report, project: project) }
- context 'with merge_base_pipelines enabled' do
- it 'returns URLs from the head_pipeline and merge_base_pipeline' do
- expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
- expect(subject[:codeclimate][:base_path]).to include("/jobs/#{merge_base_job_id}/artifacts/download?file_type=codequality")
- end
- end
-
- context 'with merge_base_pipelines disabled' do
- before do
- stub_feature_flags(merge_base_pipelines: false)
- end
-
- it 'returns URLs from the head_pipeline and base_pipeline' do
- expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
- expect(subject[:codeclimate][:base_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
- end
+ it 'returns URLs from the head_pipeline and merge_base_pipeline' do
+ expect(subject[:codeclimate][:head_path]).to include("/jobs/#{generic_job_id}/artifacts/download?file_type=codequality")
+ expect(subject[:codeclimate][:base_path]).to include("/jobs/#{merge_base_job_id}/artifacts/download?file_type=codequality")
end
end
end
@@ -333,6 +320,10 @@ RSpec.describe MergeRequestWidgetEntity do
end
context 'when suggest pipeline feature is not enabled' do
+ before do
+ stub_feature_flags(suggest_pipeline: false)
+ end
+
it 'provides no valid value for user callout path' do
expect(subject[:user_callouts_path]).to be_nil
end
diff --git a/spec/serializers/move_to_project_entity_spec.rb b/spec/serializers/move_to_project_entity_spec.rb
index a14bc3ae622..fad49ceb067 100644
--- a/spec/serializers/move_to_project_entity_spec.rb
+++ b/spec/serializers/move_to_project_entity_spec.rb
@@ -12,8 +12,12 @@ RSpec.describe MoveToProjectEntity do
expect(subject[:id]).to eq(project.id)
end
- it 'includes the full path' do
+ it 'includes the human-readable full path' do
expect(subject[:name_with_namespace]).to eq(project.name_with_namespace)
end
+
+ it 'includes the full path' do
+ expect(subject[:full_path]).to eq(project.full_path)
+ end
end
end
diff --git a/spec/serializers/paginated_diff_entity_spec.rb b/spec/serializers/paginated_diff_entity_spec.rb
index 821ed34d3ec..551b392c9e9 100644
--- a/spec/serializers/paginated_diff_entity_spec.rb
+++ b/spec/serializers/paginated_diff_entity_spec.rb
@@ -31,4 +31,50 @@ RSpec.describe PaginatedDiffEntity do
total_pages: 7
)
end
+
+ context 'when there are conflicts' do
+ let(:diff_batch) { merge_request.merge_request_diff.diffs_in_batch(7, 3, diff_options: nil) }
+ let(:diff_files) { diff_batch.diff_files.to_a }
+ let(:diff_file_with_conflict) { diff_files.last }
+ let(:diff_file_without_conflict) { diff_files.first }
+
+ let(:resolvable_conflicts) { true }
+ let(:conflict_file) { double(our_path: diff_file_with_conflict.new_path) }
+ let(:conflicts) { double(conflicts: double(files: [conflict_file]), can_be_resolved_in_ui?: resolvable_conflicts) }
+
+ let(:merge_ref_head_diff) { true }
+ let(:options) { super().merge(merge_ref_head_diff: merge_ref_head_diff) }
+
+ before do
+ allow(MergeRequests::Conflicts::ListService).to receive(:new).and_return(conflicts)
+ end
+
+ it 'conflicts are highlighted' do
+ expect(conflict_file).to receive(:diff_lines_for_serializer)
+ expect(diff_file_with_conflict).not_to receive(:diff_lines_for_serializer)
+ expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+
+ subject
+ end
+
+ context 'merge ref head diff is not chosen to be displayed' do
+ let(:merge_ref_head_diff) { false }
+
+ it 'conflicts are not calculated' do
+ expect(MergeRequests::Conflicts::ListService).not_to receive(:new)
+ end
+ end
+
+ context 'when conflicts cannot be resolved' do
+ let(:resolvable_conflicts) { false }
+
+ it 'conflicts are not highlighted' do
+ expect(conflict_file).not_to receive(:diff_lines_for_serializer)
+ expect(diff_file_with_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+ expect(diff_file_without_conflict).to receive(:diff_lines_for_serializer).twice # for highlighted_diff_lines and is_fully_expanded
+
+ subject
+ end
+ end
+ end
end
diff --git a/spec/serializers/test_case_entity_spec.rb b/spec/serializers/test_case_entity_spec.rb
index 45e63e3feec..e2b0f722f41 100644
--- a/spec/serializers/test_case_entity_spec.rb
+++ b/spec/serializers/test_case_entity_spec.rb
@@ -27,12 +27,17 @@ RSpec.describe TestCaseEntity do
context 'when test case is failed' do
let(:test_case) { create_test_case_rspec_failed }
+ before do
+ test_case.set_recent_failures(3, 'master')
+ end
+
it 'contains correct test case details' do
expect(subject[:status]).to eq('failed')
expect(subject[:name]).to eq('Test#sum when a is 1 and b is 3 returns summary')
expect(subject[:classname]).to eq('spec.test_spec')
expect(subject[:file]).to eq('./spec/test_spec.rb')
expect(subject[:execution_time]).to eq(2.22)
+ expect(subject[:recent_failures]).to eq({ count: 3, base_branch: 'master' })
end
end
@@ -42,7 +47,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :failed_with_attachment, job: job) }
+ let(:test_case) { build(:report_test_case, :failed_with_attachment, job: job) }
it 'returns the attachment_url' do
expect(subject).to include(:attachment_url)
@@ -50,7 +55,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is not present' do
- let(:test_case) { build(:test_case, job: job) }
+ let(:test_case) { build(:report_test_case, job: job) }
it 'returns a nil attachment_url' do
expect(subject[:attachment_url]).to be_nil
@@ -64,7 +69,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is present' do
- let(:test_case) { build(:test_case, :failed_with_attachment, job: job) }
+ let(:test_case) { build(:report_test_case, :failed_with_attachment, job: job) }
it 'returns no attachment_url' do
expect(subject).not_to include(:attachment_url)
@@ -72,7 +77,7 @@ RSpec.describe TestCaseEntity do
end
context 'when attachment is not present' do
- let(:test_case) { build(:test_case, job: job) }
+ let(:test_case) { build(:report_test_case, job: job) }
it 'returns no attachment_url' do
expect(subject).not_to include(:attachment_url)
diff --git a/spec/serializers/test_suite_comparer_entity_spec.rb b/spec/serializers/test_suite_comparer_entity_spec.rb
index 882991a6208..a63f5683779 100644
--- a/spec/serializers/test_suite_comparer_entity_spec.rb
+++ b/spec/serializers/test_suite_comparer_entity_spec.rb
@@ -100,109 +100,5 @@ RSpec.describe TestSuiteComparerEntity do
expect(subject[:existing_failures]).to be_empty
end
end
-
- context 'limits amount of tests returned' do
- before do
- stub_const('TestSuiteComparerEntity::DEFAULT_MAX_TESTS', 2)
- stub_const('TestSuiteComparerEntity::DEFAULT_MIN_TESTS', 1)
- end
-
- context 'prefers new over existing and resolved' do
- before do
- 3.times { add_new_failure }
- 3.times { add_new_error }
- 3.times { add_existing_failure }
- 3.times { add_existing_error }
- 3.times { add_resolved_failure }
- 3.times { add_resolved_error }
- end
-
- it 'returns 2 of each new category, and 1 of each resolved and existing' do
- expect(subject[:summary]).to include(total: 18, resolved: 6, failed: 6, errored: 6)
- expect(subject[:new_failures].count).to eq(2)
- expect(subject[:new_errors].count).to eq(2)
- expect(subject[:existing_failures].count).to eq(1)
- expect(subject[:existing_errors].count).to eq(1)
- expect(subject[:resolved_failures].count).to eq(1)
- expect(subject[:resolved_errors].count).to eq(1)
- end
- end
-
- context 'prefers existing over resolved' do
- before do
- 3.times { add_existing_failure }
- 3.times { add_existing_error }
- 3.times { add_resolved_failure }
- 3.times { add_resolved_error }
- end
-
- it 'returns 2 of each existing category, and 1 of each resolved' do
- expect(subject[:summary]).to include(total: 12, resolved: 6, failed: 3, errored: 3)
- expect(subject[:new_failures].count).to eq(0)
- expect(subject[:new_errors].count).to eq(0)
- expect(subject[:existing_failures].count).to eq(2)
- expect(subject[:existing_errors].count).to eq(2)
- expect(subject[:resolved_failures].count).to eq(1)
- expect(subject[:resolved_errors].count).to eq(1)
- end
- end
-
- context 'limits amount of resolved' do
- before do
- 3.times { add_resolved_failure }
- 3.times { add_resolved_error }
- end
-
- it 'returns 2 of each resolved category' do
- expect(subject[:summary]).to include(total: 6, resolved: 6, failed: 0, errored: 0)
- expect(subject[:new_failures].count).to eq(0)
- expect(subject[:new_errors].count).to eq(0)
- expect(subject[:existing_failures].count).to eq(0)
- expect(subject[:existing_errors].count).to eq(0)
- expect(subject[:resolved_failures].count).to eq(2)
- expect(subject[:resolved_errors].count).to eq(2)
- end
- end
-
- private
-
- def add_new_failure
- failed_case = create_test_case_rspec_failed(SecureRandom.hex)
- head_suite.add_test_case(failed_case)
- end
-
- def add_new_error
- error_case = create_test_case_rspec_error(SecureRandom.hex)
- head_suite.add_test_case(error_case)
- end
-
- def add_existing_failure
- failed_case = create_test_case_rspec_failed(SecureRandom.hex)
- base_suite.add_test_case(failed_case)
- head_suite.add_test_case(failed_case)
- end
-
- def add_existing_error
- error_case = create_test_case_rspec_error(SecureRandom.hex)
- base_suite.add_test_case(error_case)
- head_suite.add_test_case(error_case)
- end
-
- def add_resolved_failure
- case_name = SecureRandom.hex
- failed_case = create_test_case_java_failed(case_name)
- success_case = create_test_case_java_success(case_name)
- base_suite.add_test_case(failed_case)
- head_suite.add_test_case(success_case)
- end
-
- def add_resolved_error
- case_name = SecureRandom.hex
- error_case = create_test_case_java_error(case_name)
- success_case = create_test_case_java_success(case_name)
- base_suite.add_test_case(error_case)
- head_suite.add_test_case(success_case)
- end
- end
end
end
diff --git a/spec/services/admin/propagate_integration_service_spec.rb b/spec/services/admin/propagate_integration_service_spec.rb
index 5df4d9db8b1..13320528e4f 100644
--- a/spec/services/admin/propagate_integration_service_spec.rb
+++ b/spec/services/admin/propagate_integration_service_spec.rb
@@ -18,6 +18,7 @@ RSpec.describe Admin::PropagateIntegrationService do
let_it_be(:inherited_integration) do
create(:jira_service, project: create(:project), inherit_from_id: instance_integration.id)
end
+
let_it_be(:different_type_inherited_integration) do
create(:redmine_service, project: project, inherit_from_id: instance_integration.id)
end
@@ -67,7 +68,7 @@ RSpec.describe Admin::PropagateIntegrationService do
end
end
- context 'with a group without integration' do
+ context 'with a subgroup without integration' do
let(:subgroup) { create(:group, parent: group) }
it 'calls to PropagateIntegrationGroupWorker' do
@@ -77,6 +78,18 @@ RSpec.describe Admin::PropagateIntegrationService do
described_class.propagate(group_integration)
end
end
+
+ context 'with a subgroup with integration' do
+ let(:subgroup) { create(:group, parent: group) }
+ let(:subgroup_integration) { create(:jira_service, group: subgroup, project: nil, inherit_from_id: group_integration.id) }
+
+ it 'calls to PropagateIntegrationInheritDescendantWorker' do
+ expect(PropagateIntegrationInheritDescendantWorker).to receive(:perform_async)
+ .with(group_integration.id, subgroup_integration.id, subgroup_integration.id)
+
+ described_class.propagate(group_integration)
+ end
+ 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
index f2be317a13d..2834322be7b 100644
--- a/spec/services/alert_management/create_alert_issue_service_spec.rb
+++ b/spec/services/alert_management/create_alert_issue_service_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe AlertManagement::CreateAlertIssueService do
'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, reload: true) { create(:alert_management_alert, :triggered, :prometheus, project: project, payload: payload) }
let(:alert) { generic_alert }
diff --git a/spec/services/alert_management/http_integrations/create_service_spec.rb b/spec/services/alert_management/http_integrations/create_service_spec.rb
new file mode 100644
index 00000000000..ac5c62caf84
--- /dev/null
+++ b/spec/services/alert_management/http_integrations/create_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegrations::CreateService do
+ let_it_be(:user_with_permissions) { create(:user) }
+ let_it_be(:user_without_permissions) { create(:user) }
+ let_it_be_with_reload(:project) { create(:project) }
+
+ let(:current_user) { user_with_permissions }
+ let(:params) { {} }
+
+ let(:service) { described_class.new(project, current_user, params) }
+
+ before_all do
+ project.add_maintainer(user_with_permissions)
+ end
+
+ describe '#execute' do
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(response).to be_error
+ expect(response.message).to eq(message)
+ end
+ end
+
+ subject(:response) { service.execute }
+
+ context 'when the current_user is anonymous' do
+ let(:current_user) { nil }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to create an HTTP integration for this project'
+ end
+
+ context 'when current_user does not have permission to create integrations' do
+ let(:current_user) { user_without_permissions }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to create an HTTP integration for this project'
+ end
+
+ context 'when an integration already exists' do
+ let_it_be(:existing_integration) { create(:alert_management_http_integration, project: project) }
+
+ it_behaves_like 'error response', 'Multiple HTTP integrations are not supported for this project'
+ end
+
+ context 'when an error occurs during update' do
+ it_behaves_like 'error response', "Name can't be blank"
+ end
+
+ context 'with valid params' do
+ let(:params) { { name: 'New HTTP Integration', active: true } }
+
+ it 'successfully creates an integration' do
+ expect(response).to be_success
+
+ integration = response.payload[:integration]
+ expect(integration).to be_a(::AlertManagement::HttpIntegration)
+ expect(integration.name).to eq('New HTTP Integration')
+ expect(integration).to be_active
+ expect(integration.token).to be_present
+ expect(integration.endpoint_identifier).to be_present
+ end
+ end
+ end
+end
diff --git a/spec/services/alert_management/http_integrations/destroy_service_spec.rb b/spec/services/alert_management/http_integrations/destroy_service_spec.rb
new file mode 100644
index 00000000000..cd949d728de
--- /dev/null
+++ b/spec/services/alert_management/http_integrations/destroy_service_spec.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegrations::DestroyService do
+ let_it_be(:user_with_permissions) { create(:user) }
+ let_it_be(:user_without_permissions) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let!(:integration) { create(:alert_management_http_integration, project: project) }
+ let(:current_user) { user_with_permissions }
+ let(:params) { {} }
+ let(:service) { described_class.new(integration, current_user) }
+
+ before_all do
+ project.add_maintainer(user_with_permissions)
+ end
+
+ describe '#execute' do
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(response).to be_error
+ expect(response.message).to eq(message)
+ end
+ end
+
+ subject(:response) { service.execute }
+
+ context 'when the current_user is anonymous' do
+ let(:current_user) { nil }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to remove this HTTP integration'
+ end
+
+ context 'when current_user does not have permission to create integrations' do
+ let(:current_user) { user_without_permissions }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to remove this HTTP integration'
+ end
+
+ context 'when an error occurs during removal' do
+ before do
+ allow(integration).to receive(:destroy).and_return(false)
+ integration.errors.add(:name, 'cannot be removed')
+ end
+
+ it_behaves_like 'error response', 'Name cannot be removed'
+ end
+
+ it 'successfully returns the integration' do
+ expect(response).to be_success
+
+ integration_result = response.payload[:integration]
+ expect(integration_result).to be_a(::AlertManagement::HttpIntegration)
+ expect(integration_result.name).to eq(integration.name)
+ expect(integration_result.active).to eq(integration.active)
+ expect(integration_result.token).to eq(integration.token)
+ expect(integration_result.endpoint_identifier).to eq(integration.endpoint_identifier)
+
+ expect { integration.reload }.to raise_error ActiveRecord::RecordNotFound
+ end
+ end
+end
diff --git a/spec/services/alert_management/http_integrations/update_service_spec.rb b/spec/services/alert_management/http_integrations/update_service_spec.rb
new file mode 100644
index 00000000000..94c34d9a29c
--- /dev/null
+++ b/spec/services/alert_management/http_integrations/update_service_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::HttpIntegrations::UpdateService do
+ let_it_be(:user_with_permissions) { create(:user) }
+ let_it_be(:user_without_permissions) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be_with_reload(:integration) { create(:alert_management_http_integration, :inactive, project: project, name: 'Old Name') }
+
+ let(:current_user) { user_with_permissions }
+ let(:params) { {} }
+
+ let(:service) { described_class.new(integration, current_user, params) }
+
+ before_all do
+ project.add_maintainer(user_with_permissions)
+ end
+
+ describe '#execute' do
+ shared_examples 'error response' do |message|
+ it 'has an informative message' do
+ expect(response).to be_error
+ expect(response.message).to eq(message)
+ end
+ end
+
+ subject(:response) { service.execute }
+
+ context 'when the current_user is anonymous' do
+ let(:current_user) { nil }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to update this HTTP integration'
+ end
+
+ context 'when current_user does not have permission to create integrations' do
+ let(:current_user) { user_without_permissions }
+
+ it_behaves_like 'error response', 'You have insufficient permissions to update this HTTP integration'
+ end
+
+ context 'when an error occurs during update' do
+ let(:params) { { name: '' } }
+
+ it_behaves_like 'error response', "Name can't be blank"
+ end
+
+ context 'with name param' do
+ let(:params) { { name: 'New Name' } }
+
+ it 'successfully updates the integration' do
+ expect(response).to be_success
+ expect(response.payload[:integration].name).to eq('New Name')
+ end
+ end
+
+ context 'with active param' do
+ let(:params) { { active: true } }
+
+ it 'successfully updates the integration' do
+ expect(response).to be_success
+ expect(response.payload[:integration]).to be_active
+ end
+ end
+
+ context 'with regenerate_token flag' do
+ let(:params) { { regenerate_token: true } }
+
+ it 'successfully updates the integration' do
+ previous_token = integration.token
+
+ expect(response).to be_success
+ expect(response.payload[:integration].token).not_to eq(previous_token)
+ 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
index ae0b8d6d7ac..2f920de7fc7 100644
--- a/spec/services/alert_management/process_prometheus_alert_service_spec.rb
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -11,9 +11,16 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
describe '#execute' do
let(:service) { described_class.new(project, nil, payload) }
- let(:incident_management_setting) { double(auto_close_incident?: auto_close_incident, create_issue?: create_issue) }
let(:auto_close_incident) { true }
let(:create_issue) { true }
+ let(:send_email) { true }
+ let(:incident_management_setting) do
+ double(
+ auto_close_incident?: auto_close_incident,
+ create_issue?: create_issue,
+ send_email?: send_email
+ )
+ end
before do
allow(service)
@@ -55,6 +62,7 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
it_behaves_like 'adds an alert management alert event'
it_behaves_like 'processes incident issues'
+ it_behaves_like 'Alert Notification Service sends notification email'
context 'existing alert is resolved' do
let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: fingerprint) }
@@ -92,28 +100,48 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
end
end
- context 'when auto-alert creation is disabled' do
+ context 'when auto-creation of issues is disabled' do
let(:create_issue) { false }
it_behaves_like 'does not process incident issues'
end
+
+ context 'when emails are disabled' do
+ let(:send_email) { false }
+
+ it 'does not send notification' do
+ expect(NotificationService).not_to receive(:new)
+
+ expect(subject).to be_success
+ end
+ end
end
context 'when alert does not exist' do
context 'when alert can be created' do
it_behaves_like 'creates an alert management alert'
+ it_behaves_like 'Alert Notification Service sends notification email'
+ it_behaves_like 'processes incident issues'
it 'creates a system note corresponding to alert creation' do
expect { subject }.to change(Note, :count).by(1)
end
- it_behaves_like 'processes incident issues'
-
context 'when auto-alert creation is disabled' do
let(:create_issue) { false }
it_behaves_like 'does not process incident issues'
end
+
+ context 'when emails are disabled' do
+ let(:send_email) { false }
+
+ it 'does not send notification' do
+ expect(NotificationService).not_to receive(:new)
+
+ expect(subject).to be_success
+ end
+ end
end
context 'when alert cannot be created' do
@@ -125,6 +153,9 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
allow(service).to receive_message_chain(:alert, :errors).and_return(errors)
end
+ it_behaves_like 'Alert Notification Service sends no notifications', http_status: :bad_request
+ it_behaves_like 'does not process incident issues due to error', http_status: :bad_request
+
it 'writes a warning to the log' do
expect(Gitlab::AppLogger).to receive(:warn).with(
message: 'Unable to create AlertManagement::Alert',
@@ -134,8 +165,6 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
execute
end
-
- it_behaves_like 'does not process incident issues'
end
it { is_expected.to be_success }
@@ -148,6 +177,9 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
context 'when auto_resolve_incident set to true' do
context 'when status can be changed' do
+ it_behaves_like 'Alert Notification Service sends notification email'
+ it_behaves_like 'does not process incident issues'
+
it 'resolves an existing alert' do
expect { execute }.to change { alert.reload.resolved? }.to(true)
end
@@ -185,6 +217,8 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
execute
end
+
+ it_behaves_like 'Alert Notification Service sends notification email'
end
it { is_expected.to be_success }
@@ -197,6 +231,16 @@ RSpec.describe AlertManagement::ProcessPrometheusAlertService do
expect { execute }.not_to change { alert.reload.resolved? }
end
end
+
+ context 'when emails are disabled' do
+ let(:send_email) { false }
+
+ it 'does not send notification' do
+ expect(NotificationService).not_to receive(:new)
+
+ expect(subject).to be_success
+ end
+ end
end
context 'environment given' do
diff --git a/spec/services/alert_management/sync_alert_service_data_service_spec.rb b/spec/services/alert_management/sync_alert_service_data_service_spec.rb
new file mode 100644
index 00000000000..ecec60011db
--- /dev/null
+++ b/spec/services/alert_management/sync_alert_service_data_service_spec.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::SyncAlertServiceDataService do
+ let_it_be(:alerts_service) do
+ AlertsService.skip_callback(:save, :after, :update_http_integration)
+ service = create(:alerts_service, :active)
+ AlertsService.set_callback(:save, :after, :update_http_integration)
+
+ service
+ end
+
+ describe '#execute' do
+ subject(:execute) { described_class.new(alerts_service).execute }
+
+ context 'without http integration' do
+ it 'creates the integration' do
+ expect { execute }
+ .to change { AlertManagement::HttpIntegration.count }.by(1)
+ end
+
+ it 'returns a success' do
+ expect(subject.success?).to eq(true)
+ end
+ end
+
+ context 'existing legacy http integration' do
+ let_it_be(:integration) { create(:alert_management_http_integration, :legacy, project: alerts_service.project) }
+
+ it 'updates the integration' do
+ expect { execute }
+ .to change { integration.reload.encrypted_token }.to(alerts_service.data.encrypted_token)
+ .and change { integration.encrypted_token_iv }.to(alerts_service.data.encrypted_token_iv)
+ end
+
+ it 'returns a success' do
+ expect(subject.success?).to eq(true)
+ end
+ end
+
+ context 'existing other http integration' do
+ let_it_be(:integration) { create(:alert_management_http_integration, project: alerts_service.project) }
+
+ it 'creates the integration' do
+ expect { execute }
+ .to change { AlertManagement::HttpIntegration.count }.by(1)
+ end
+
+ it 'returns a success' do
+ expect(subject.success?).to eq(true)
+ end
+ end
+ end
+end
diff --git a/spec/services/application_settings/update_service_spec.rb b/spec/services/application_settings/update_service_spec.rb
index e5060fa2eeb..03e55b3ec46 100644
--- a/spec/services/application_settings/update_service_spec.rb
+++ b/spec/services/application_settings/update_service_spec.rb
@@ -66,7 +66,7 @@ RSpec.describe ApplicationSettings::UpdateService do
context 'when params is blank' do
let(:params) { {} }
- it 'does not add to whitelist' do
+ it 'does not add to allowlist' do
expect { subject.execute }.not_to change {
application_settings.outbound_local_requests_whitelist
}
@@ -80,7 +80,7 @@ RSpec.describe ApplicationSettings::UpdateService do
let(:params) { { add_to_outbound_local_requests_whitelist: ['example.com', ''] } }
- it 'adds to whitelist' do
+ it 'adds to allowlist' do
expect { subject.execute }.to change {
application_settings.outbound_local_requests_whitelist
}
@@ -91,14 +91,14 @@ RSpec.describe ApplicationSettings::UpdateService do
end
end
- context 'when param outbound_local_requests_whitelist_raw is passed' do
+ context 'when param outbound_local_requests_allowlist_raw is passed' do
before do
application_settings.outbound_local_requests_whitelist = ['127.0.0.1']
end
- let(:params) { { outbound_local_requests_whitelist_raw: 'example.com;gitlab.com' } }
+ let(:params) { { outbound_local_requests_allowlist_raw: 'example.com;gitlab.com' } }
- it 'overwrites the existing whitelist' do
+ it 'overwrites the existing allowlist' do
expect { subject.execute }.to change {
application_settings.outbound_local_requests_whitelist
}
diff --git a/spec/services/audit_event_service_spec.rb b/spec/services/audit_event_service_spec.rb
index 3317fcf8444..997f506c269 100644
--- a/spec/services/audit_event_service_spec.rb
+++ b/spec/services/audit_event_service_spec.rb
@@ -3,17 +3,13 @@
require 'spec_helper'
RSpec.describe AuditEventService do
- let(:project) { create(:project) }
- let(:user) { create(:user, :with_sign_ins) }
- let(:project_member) { create(:project_member, user: user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:user) { create(:user, :with_sign_ins) }
+ let_it_be(:project_member) { create(:project_member, user: user) }
let(:service) { described_class.new(user, project, { action: :destroy }) }
let(:logger) { instance_double(Gitlab::AuditJsonLogger) }
describe '#security_event' do
- before do
- stub_licensed_features(admin_audit_log: false)
- end
-
it 'creates an event and logs to a file' do
expect(service).to receive(:file_logger).and_return(logger)
expect(logger).to receive(:info).with(author_id: user.id,
@@ -78,6 +74,31 @@ RSpec.describe AuditEventService do
audit_service.for_authentication.security_event
end
+
+ context 'with IP address', :request_store do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:from_caller, :from_context, :from_author_sign_in, :output) do
+ '192.168.0.1' | '192.168.0.2' | '192.168.0.3' | '192.168.0.1'
+ nil | '192.168.0.2' | '192.168.0.3' | '192.168.0.2'
+ nil | nil | '192.168.0.3' | '192.168.0.3'
+ end
+
+ with_them do
+ let(:user) { create(:user, current_sign_in_ip: from_author_sign_in) }
+ let(:audit_service) { described_class.new(user, user, with: 'standard', ip_address: from_caller) }
+
+ before do
+ allow(Gitlab::RequestContext.instance).to receive(:client_ip).and_return(from_context)
+ end
+
+ specify do
+ expect(AuthenticationEvent).to receive(:new).with(hash_including(ip_address: output))
+
+ audit_service.for_authentication.security_event
+ end
+ end
+ end
end
end
diff --git a/spec/services/auth/container_registry_authentication_service_spec.rb b/spec/services/auth/container_registry_authentication_service_spec.rb
index bc85f4f0087..90ef32f1c5c 100644
--- a/spec/services/auth/container_registry_authentication_service_spec.rb
+++ b/spec/services/auth/container_registry_authentication_service_spec.rb
@@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Auth::ContainerRegistryAuthenticationService do
+ include AdminModeHelper
+
let(:current_project) { nil }
let(:current_user) { nil }
let(:current_params) { {} }
@@ -135,7 +137,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
describe '#full_access_token' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
let(:token) { described_class.full_access_token(project.full_path) }
subject { { token: token } }
@@ -148,7 +150,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
describe '#pull_access_token' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
let(:token) { described_class.pull_access_token(project.full_path) }
subject { { token: token } }
@@ -161,7 +163,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'user authorization' do
- let(:current_user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
context 'for registry catalog' do
let(:current_params) do
@@ -175,14 +177,14 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private project' do
- let(:project) { create(:project) }
+ let_it_be(:project) { create(:project) }
context 'allow to use scope-less authentication' do
it_behaves_like 'a valid token'
end
context 'allow developer to push images' do
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -195,7 +197,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow developer to delete images' do
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -222,7 +224,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow developer to delete images since registry 2.7' do
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -235,7 +237,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'allow reporter to pull images' do
- before do
+ before_all do
project.add_reporter(current_user)
end
@@ -250,7 +252,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow reporter to delete images' do
- before do
+ before_all do
project.add_reporter(current_user)
end
@@ -263,7 +265,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow reporter to delete images since registry 2.7' do
- before do
+ before_all do
project.add_reporter(current_user)
end
@@ -276,7 +278,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'return a least of privileges' do
- before do
+ before_all do
project.add_reporter(current_user)
end
@@ -289,7 +291,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow guest to pull or push images' do
- before do
+ before_all do
project.add_guest(current_user)
end
@@ -302,7 +304,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow guest to delete images' do
- before do
+ before_all do
project.add_guest(current_user)
end
@@ -315,7 +317,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow guest to delete images since registry 2.7' do
- before do
+ before_all do
project.add_guest(current_user)
end
@@ -329,7 +331,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
context 'allow anyone to pull images' do
let(:current_params) do
@@ -378,7 +380,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for internal project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'for internal user' do
context 'allow anyone to pull images' do
@@ -420,7 +422,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
context 'for external user' do
context 'disallow anyone to pull or push images' do
- let(:current_user) { create(:user, external: true) }
+ let_it_be(:current_user) { create(:user, external: true) }
let(:current_params) do
{ scopes: ["repository:#{project.full_path}:pull,push"] }
end
@@ -430,7 +432,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow anyone to delete images' do
- let(:current_user) { create(:user, external: true) }
+ let_it_be(:current_user) { create(:user, external: true) }
let(:current_params) do
{ scopes: ["repository:#{project.full_path}:*"] }
end
@@ -440,7 +442,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'disallow anyone to delete images since registry 2.7' do
- let(:current_user) { create(:user, external: true) }
+ let_it_be(:current_user) { create(:user, external: true) }
let(:current_params) do
{ scopes: ["repository:#{project.full_path}:delete"] }
end
@@ -453,14 +455,14 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'delete authorized as maintainer' do
- let(:current_project) { create(:project) }
- let(:current_user) { create(:user) }
+ let_it_be(:current_project) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
let(:authentication_abilities) do
[:admin_container_image]
end
- before do
+ before_all do
current_project.add_maintainer(current_user)
end
@@ -488,14 +490,14 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'build authorized as user' do
- let(:current_project) { create(:project) }
- let(:current_user) { create(:user) }
+ let_it_be(:current_project) { create(:project) }
+ let_it_be(:current_user) { create(:user) }
let(:authentication_abilities) do
[:build_read_container_image, :build_create_container_image, :build_destroy_container_image]
end
- before do
+ before_all do
current_project.add_developer(current_user)
end
@@ -550,7 +552,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'allow for public' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -563,7 +565,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when you are member' do
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -572,7 +574,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when you are owner' do
- let(:project) { create(:project, namespace: current_user.namespace) }
+ let_it_be(:project) { create(:project, namespace: current_user.namespace) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -580,12 +582,12 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private' do
- let(:project) { create(:project, :private) }
+ let_it_be(:project) { create(:project, :private) }
it_behaves_like 'pullable for being team member'
context 'when you are admin' do
- let(:current_user) { create(:admin) }
+ let_it_be(:current_user) { create(:admin) }
context 'when you are not member' do
it_behaves_like 'an inaccessible'
@@ -593,7 +595,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when you are member' do
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -602,7 +604,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when you are owner' do
- let(:project) { create(:project, namespace: current_user.namespace) }
+ let_it_be(:project) { create(:project, namespace: current_user.namespace) }
it_behaves_like 'a pullable'
it_behaves_like 'not a container repository factory'
@@ -618,9 +620,9 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
context 'disallow for all' do
context 'when you are member' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
- before do
+ before_all do
project.add_developer(current_user)
end
@@ -629,7 +631,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when you are owner' do
- let(:project) { create(:project, :public, namespace: current_user.namespace) }
+ let_it_be(:project) { create(:project, :public, namespace: current_user.namespace) }
it_behaves_like 'an inaccessible'
it_behaves_like 'not a container repository factory'
@@ -639,10 +641,10 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for project without container registry' do
- let(:project) { create(:project, :public, container_registry_enabled: false) }
+ let_it_be(:project) { create(:project, :public, container_registry_enabled: false) }
before do
- project.update(container_registry_enabled: false)
+ project.update!(container_registry_enabled: false)
end
context 'disallow when pulling' do
@@ -656,7 +658,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for project that disables repository' do
- let(:project) { create(:project, :public, :repository_disabled) }
+ let_it_be(:project) { create(:project, :public, :repository_disabled) }
context 'disallow when pulling' do
let(:current_params) do
@@ -670,8 +672,8 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'registry catalog browsing authorized as admin' do
- let(:current_user) { create(:user, :admin) }
- let(:project) { create(:project, :public) }
+ let_it_be(:current_user) { create(:user, :admin) }
+ let_it_be(:project) { create(:project, :public) }
let(:current_params) do
{ scopes: ["registry:catalog:*"] }
@@ -681,8 +683,8 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'support for multiple scopes' do
- let(:internal_project) { create(:project, :internal) }
- let(:private_project) { create(:project, :private) }
+ let_it_be(:internal_project) { create(:project, :internal) }
+ let_it_be(:private_project) { create(:project, :private) }
let(:current_params) do
{
@@ -694,7 +696,11 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'user has access to all projects' do
- let(:current_user) { create(:user, :admin) }
+ let_it_be(:current_user) { create(:user, :admin) }
+
+ before do
+ enable_admin_mode!(current_user)
+ end
it_behaves_like 'a browsable' do
let(:access) do
@@ -711,7 +717,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'user only has access to internal project' do
- let(:current_user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
it_behaves_like 'a browsable' do
let(:access) do
@@ -747,7 +753,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private project' do
- let(:project) { create(:project, :private) }
+ let_it_be(:project) { create(:project, :private) }
let(:current_params) do
{ scopes: ["repository:#{project.full_path}:pull"] }
@@ -757,7 +763,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
context 'when pulling and pushing' do
let(:current_params) do
@@ -806,7 +812,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
context 'when pulling' do
it_behaves_like 'a pullable'
@@ -824,7 +830,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for internal project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when pulling' do
it_behaves_like 'a pullable'
@@ -842,7 +848,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private project' do
- let(:project) { create(:project, :private) }
+ let_it_be(:project) { create(:project, :private) }
context 'when pulling' do
it_behaves_like 'a pullable'
@@ -880,7 +886,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
context 'when pulling' do
it_behaves_like 'a pullable'
@@ -890,7 +896,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for internal project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when pulling' do
it_behaves_like 'an inaccessible'
@@ -900,7 +906,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when pulling' do
it_behaves_like 'an inaccessible'
@@ -918,10 +924,10 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'when deploy token is not related to the project' do
- let(:current_user) { create(:deploy_token, read_registry: false) }
+ let_it_be(:current_user) { create(:deploy_token, read_registry: false) }
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
context 'when pulling' do
it_behaves_like 'a pullable'
@@ -929,7 +935,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for internal project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when pulling' do
it_behaves_like 'an inaccessible'
@@ -937,7 +943,7 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'for private project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
context 'when pulling' do
it_behaves_like 'an inaccessible'
@@ -949,19 +955,19 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
let(:current_user) { create(:deploy_token, :revoked, projects: [project]) }
context 'for public project' do
- let(:project) { create(:project, :public) }
+ let_it_be(:project) { create(:project, :public) }
it_behaves_like 'a pullable'
end
context 'for internal project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
it_behaves_like 'an inaccessible'
end
context 'for private project' do
- let(:project) { create(:project, :internal) }
+ let_it_be(:project) { create(:project, :internal) }
it_behaves_like 'an inaccessible'
end
@@ -969,14 +975,13 @@ RSpec.describe Auth::ContainerRegistryAuthenticationService do
end
context 'user authorization' do
- let(:current_user) { create(:user) }
+ let_it_be(:current_user) { create(:user) }
context 'with multiple scopes' do
- let(:project) { create(:project) }
- let(:project2) { create }
+ let_it_be(:project) { create(:project) }
context 'allow developer to push images' do
- before do
+ before_all do
project.add_developer(current_user)
end
diff --git a/spec/services/auto_merge/base_service_spec.rb b/spec/services/auto_merge/base_service_spec.rb
index 98fa6012089..1d33dc15838 100644
--- a/spec/services/auto_merge/base_service_spec.rb
+++ b/spec/services/auto_merge/base_service_spec.rb
@@ -131,7 +131,7 @@ RSpec.describe AutoMerge::BaseService do
end
describe '#update' do
- subject { service.update(merge_request) }
+ subject { service.update(merge_request) } # rubocop:disable Rails/SaveBang
let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
diff --git a/spec/services/auto_merge_service_spec.rb b/spec/services/auto_merge_service_spec.rb
index eab95973e1b..3f7a26aa00e 100644
--- a/spec/services/auto_merge_service_spec.rb
+++ b/spec/services/auto_merge_service_spec.rb
@@ -148,7 +148,7 @@ RSpec.describe AutoMergeService do
end
describe '#update' do
- subject { service.update(merge_request) }
+ subject { service.update(merge_request) } # rubocop:disable Rails/SaveBang
context 'when auto merge is enabled' do
let(:merge_request) { create(:merge_request, :merge_when_pipeline_succeeds) }
diff --git a/spec/services/bulk_create_integration_service_spec.rb b/spec/services/bulk_create_integration_service_spec.rb
index 5d896f78b35..674382ee14f 100644
--- a/spec/services/bulk_create_integration_service_spec.rb
+++ b/spec/services/bulk_create_integration_service_spec.rb
@@ -5,13 +5,15 @@ require 'spec_helper'
RSpec.describe BulkCreateIntegrationService do
include JiraServiceHelper
- before do
+ before_all do
stub_jira_service_test
end
+ let_it_be(:excluded_group) { create(:group) }
+ let_it_be(:excluded_project) { create(:project, group: excluded_group) }
+ let(:instance_integration) { create(:jira_service, :instance) }
+ let(:template_integration) { create(:jira_service, :template) }
let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] }
- let!(:instance_integration) { create(:jira_service, :instance) }
- let!(:template_integration) { create(:jira_service, :template) }
shared_examples 'creates integration from batch ids' do
it 'updates the inherited integrations' do
@@ -37,71 +39,125 @@ RSpec.describe BulkCreateIntegrationService do
it 'updates inherit_from_id attributes' do
described_class.new(integration, batch, association).execute
- expect(created_integration.reload.inherit_from_id).to eq(integration.id)
+ expect(created_integration.reload.inherit_from_id).to eq(inherit_from_id)
end
end
- shared_examples 'runs project callbacks' do
+ shared_examples 'updates project callbacks' do
it 'updates projects#has_external_issue_tracker for issue tracker services' do
described_class.new(integration, batch, association).execute
expect(project.reload.has_external_issue_tracker).to eq(true)
+ expect(excluded_project.reload.has_external_issue_tracker).to eq(false)
end
context 'with an external wiki integration' do
- let(:integration) do
- ExternalWikiService.create!(
- instance: true,
- active: true,
- push_events: false,
- external_wiki_url: 'http://external-wiki-url.com'
- )
+ before do
+ integration.update!(category: 'common', type: 'ExternalWikiService')
end
it 'updates projects#has_external_wiki for external wiki services' do
described_class.new(integration, batch, association).execute
expect(project.reload.has_external_wiki).to eq(true)
+ expect(excluded_project.reload.has_external_wiki).to eq(false)
end
end
end
- context 'with an instance-level integration' do
+ shared_examples 'does not update project callbacks' do
+ it 'does not update projects#has_external_issue_tracker for issue tracker services' do
+ described_class.new(integration, batch, association).execute
+
+ expect(project.reload.has_external_issue_tracker).to eq(false)
+ end
+
+ context 'with an inactive external wiki integration' do
+ let(:integration) { create(:external_wiki_service, :instance, active: false) }
+
+ it 'does not update projects#has_external_wiki for external wiki services' do
+ described_class.new(integration, batch, association).execute
+
+ expect(project.reload.has_external_wiki).to eq(false)
+ end
+ end
+ end
+
+ context 'passing an instance-level integration' do
let(:integration) { instance_integration }
+ let(:inherit_from_id) { integration.id }
context 'with a project association' do
let!(:project) { create(:project) }
let(:created_integration) { project.jira_service }
- let(:batch) { Project.all }
+ let(:batch) { Project.where(id: project.id) }
let(:association) { 'project' }
it_behaves_like 'creates integration from batch ids'
it_behaves_like 'updates inherit_from_id'
- it_behaves_like 'runs project callbacks'
+ it_behaves_like 'updates project callbacks'
+
+ context 'when integration is not active' do
+ before do
+ integration.update!(active: false)
+ end
+
+ it_behaves_like 'does not update project callbacks'
+ end
end
context 'with a group association' do
let!(:group) { create(:group) }
let(:created_integration) { Service.find_by(group: group) }
- let(:batch) { Group.all }
+ let(:batch) { Group.where(id: group.id) }
+ let(:association) { 'group' }
+
+ it_behaves_like 'creates integration from batch ids'
+ it_behaves_like 'updates inherit_from_id'
+ end
+ end
+
+ context 'passing a group integration' do
+ let_it_be(:group) { create(:group) }
+
+ context 'with a project association' do
+ let!(:project) { create(:project, group: group) }
+ let(:integration) { create(:jira_service, group: group, project: nil) }
+ let(:created_integration) { project.jira_service }
+ let(:batch) { Project.where(id: Project.minimum(:id)..Project.maximum(:id)).without_integration(integration).in_namespace(integration.group.self_and_descendants) }
+ let(:association) { 'project' }
+ let(:inherit_from_id) { integration.id }
+
+ it_behaves_like 'creates integration from batch ids'
+ it_behaves_like 'updates inherit_from_id'
+ it_behaves_like 'updates project callbacks'
+ end
+
+ context 'with a group association' do
+ let!(:subgroup) { create(:group, parent: group) }
+ let(:integration) { create(:jira_service, group: group, project: nil, inherit_from_id: instance_integration.id) }
+ let(:created_integration) { Service.find_by(group: subgroup) }
+ let(:batch) { Group.where(id: subgroup.id) }
let(:association) { 'group' }
+ let(:inherit_from_id) { instance_integration.id }
it_behaves_like 'creates integration from batch ids'
it_behaves_like 'updates inherit_from_id'
end
end
- context 'with a template integration' do
+ context 'passing a template integration' do
let(:integration) { template_integration }
context 'with a project association' do
let!(:project) { create(:project) }
let(:created_integration) { project.jira_service }
- let(:batch) { Project.all }
+ let(:batch) { Project.where(id: project.id) }
let(:association) { 'project' }
+ let(:inherit_from_id) { integration.id }
it_behaves_like 'creates integration from batch ids'
- it_behaves_like 'runs project callbacks'
+ it_behaves_like 'updates project callbacks'
end
end
end
diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb
new file mode 100644
index 00000000000..e4a50b9d523
--- /dev/null
+++ b/spec/services/bulk_import_service_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImportService do
+ let(:user) { create(:user) }
+ let(:credentials) { { url: 'http://gitlab.example', access_token: 'token' } }
+ let(:params) do
+ [
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group1',
+ destination_name: 'destination group 1',
+ destination_namespace: 'full/path/to/destination1'
+ },
+ {
+ source_type: 'group_entity',
+ source_full_path: 'full/path/to/group2',
+ destination_name: 'destination group 2',
+ destination_namespace: 'full/path/to/destination2'
+ },
+ {
+ source_type: 'project_entity',
+ source_full_path: 'full/path/to/project1',
+ destination_name: 'destination project 1',
+ destination_namespace: 'full/path/to/destination1'
+ }
+ ]
+ end
+
+ subject { described_class.new(user, params, credentials) }
+
+ describe '#execute' do
+ it 'creates bulk import' do
+ expect { subject.execute }.to change { BulkImport.count }.by(1)
+ end
+
+ it 'creates bulk import entities' do
+ expect { subject.execute }.to change { BulkImports::Entity.count }.by(3)
+ end
+
+ it 'creates bulk import configuration' do
+ expect { subject.execute }.to change { BulkImports::Configuration.count }.by(1)
+ end
+
+ it 'enqueues BulkImportWorker' do
+ expect(BulkImportWorker).to receive(:perform_async)
+
+ subject.execute
+ end
+ end
+end
diff --git a/spec/services/bulk_update_integration_service_spec.rb b/spec/services/bulk_update_integration_service_spec.rb
index 2f0bfd31600..e7944f07bb7 100644
--- a/spec/services/bulk_update_integration_service_spec.rb
+++ b/spec/services/bulk_update_integration_service_spec.rb
@@ -5,52 +5,74 @@ require 'spec_helper'
RSpec.describe BulkUpdateIntegrationService do
include JiraServiceHelper
- before do
+ before_all do
stub_jira_service_test
end
let(:excluded_attributes) { %w[id project_id group_id inherit_from_id instance template created_at updated_at] }
- let!(:instance_integration) do
+ let(:batch) do
+ Service.inherited_descendants_from_self_or_ancestors_from(subgroup_integration).where(id: group_integration.id..integration.id)
+ end
+
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:group_integration) do
+ JiraService.create!(
+ group: group,
+ url: 'http://group.jira.com'
+ )
+ end
+
+ let_it_be(:subgroup_integration) do
JiraService.create!(
- instance: true,
- active: true,
- push_events: true,
- url: 'http://update-jira.instance.com',
- username: 'user',
- password: 'secret'
+ inherit_from_id: group_integration.id,
+ group: subgroup,
+ url: 'http://subgroup.jira.com',
+ push_events: true
)
end
- let!(:integration) do
+ let_it_be(:excluded_integration) do
JiraService.create!(
- project: create(:project),
- inherit_from_id: instance_integration.id,
- instance: false,
- active: true,
- push_events: false,
- url: 'http://jira.instance.com',
- username: 'user',
- password: 'secret'
+ group: create(:group),
+ url: 'http://another.jira.com',
+ push_events: false
+ )
+ end
+
+ let_it_be(:integration) do
+ JiraService.create!(
+ project: create(:project, group: subgroup),
+ inherit_from_id: subgroup_integration.id,
+ url: 'http://project.jira.com',
+ push_events: false
)
end
context 'with inherited integration' do
- it 'updates the integration' do
- described_class.new(instance_integration, Service.inherit_from_id(instance_integration.id)).execute
+ it 'updates the integration', :aggregate_failures do
+ described_class.new(subgroup_integration, batch).execute
+
+ expect(integration.reload.inherit_from_id).to eq(group_integration.id)
+ expect(integration.reload.attributes.except(*excluded_attributes))
+ .to eq(subgroup_integration.attributes.except(*excluded_attributes))
- expect(integration.reload.inherit_from_id).to eq(instance_integration.id)
- expect(integration.attributes.except(*excluded_attributes))
- .to eq(instance_integration.attributes.except(*excluded_attributes))
+ expect(excluded_integration.reload.inherit_from_id).not_to eq(group_integration.id)
+ expect(excluded_integration.reload.attributes.except(*excluded_attributes))
+ .not_to eq(subgroup_integration.attributes.except(*excluded_attributes))
end
context 'with integration with data fields' do
let(:excluded_attributes) { %w[id service_id created_at updated_at] }
- it 'updates the data fields from the integration' do
- described_class.new(instance_integration, Service.inherit_from_id(instance_integration.id)).execute
+ it 'updates the data fields from the integration', :aggregate_failures do
+ described_class.new(subgroup_integration, batch).execute
+
+ expect(integration.data_fields.attributes.except(*excluded_attributes))
+ .to eq(subgroup_integration.data_fields.attributes.except(*excluded_attributes))
- expect(integration.reload.data_fields.attributes.except(*excluded_attributes))
- .to eq(instance_integration.data_fields.attributes.except(*excluded_attributes))
+ expect(integration.data_fields.attributes.except(*excluded_attributes))
+ .not_to eq(excluded_integration.data_fields.attributes.except(*excluded_attributes))
end
end
end
diff --git a/spec/services/ci/append_build_trace_service_spec.rb b/spec/services/ci/append_build_trace_service_spec.rb
new file mode 100644
index 00000000000..a0a7f594881
--- /dev/null
+++ b/spec/services/ci/append_build_trace_service_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::AppendBuildTraceService do
+ let_it_be(:project) { create(:project) }
+ let_it_be(:pipeline) { create(:ci_pipeline, project: project) }
+ let_it_be(:build) { create(:ci_build, :running, pipeline: pipeline) }
+
+ before do
+ stub_feature_flags(ci_enable_live_trace: true)
+ end
+
+ context 'build trace append is successful' do
+ it 'returns a correct stream size and status code' do
+ stream_size = 192.kilobytes
+ body_data = 'x' * stream_size
+ content_range = "0-#{stream_size}"
+
+ result = described_class
+ .new(build, content_range: content_range)
+ .execute(body_data)
+
+ expect(result.status).to eq 202
+ expect(result.stream_size).to eq stream_size
+ expect(build.trace_chunks.count).to eq 2
+ end
+ end
+
+ context 'when could not correctly append to a trace' do
+ it 'responds with content range violation and data stored' do
+ allow(build).to receive_message_chain(:trace, :append) { 16 }
+
+ result = described_class
+ .new(build, content_range: '0-128')
+ .execute('x' * 128)
+
+ expect(result.status).to eq 416
+ expect(result.stream_size).to eq 16
+ end
+
+ it 'logs exception if build has live trace' do
+ build.trace.append('abcd', 0)
+
+ expect(::Gitlab::ErrorTracking)
+ .to receive(:log_exception)
+ .with(anything, hash_including(chunk_index: 0, chunk_store: 'redis'))
+
+ result = described_class
+ .new(build, content_range: '0-128')
+ .execute('x' * 128)
+
+ expect(result.status).to eq 416
+ expect(result.stream_size).to eq 4
+ end
+ end
+end
diff --git a/spec/services/ci/build_report_result_service_spec.rb b/spec/services/ci/build_report_result_service_spec.rb
index 134b662a72a..7c2702af086 100644
--- a/spec/services/ci/build_report_result_service_spec.rb
+++ b/spec/services/ci/build_report_result_service_spec.rb
@@ -26,18 +26,6 @@ RSpec.describe Ci::BuildReportResultService do
expect(unique_test_cases_parsed).to eq(4)
end
- context 'when feature flag for tracking is disabled' do
- before do
- stub_feature_flags(track_unique_test_cases_parsed: false)
- end
-
- it 'creates the report but does not track the event' do
- expect(Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
- expect(build_report_result.tests_name).to eq("test")
- expect(Ci::BuildReportResult.count).to eq(1)
- end
- end
-
context 'when data has already been persisted' do
it 'raises an error and do not persist the same data twice' do
expect { 2.times { described_class.new.execute(build) } }.to raise_error(ActiveRecord::RecordNotUnique)
diff --git a/spec/services/ci/compare_test_reports_service_spec.rb b/spec/services/ci/compare_test_reports_service_spec.rb
index 7d31db73b6a..377c801b008 100644
--- a/spec/services/ci/compare_test_reports_service_spec.rb
+++ b/spec/services/ci/compare_test_reports_service_spec.rb
@@ -7,15 +7,15 @@ RSpec.describe Ci::CompareTestReportsService do
let(:project) { create(:project, :repository) }
describe '#execute' do
- subject { service.execute(base_pipeline, head_pipeline) }
+ subject(:comparison) { service.execute(base_pipeline, head_pipeline) }
context 'when head pipeline has test reports' do
let!(:base_pipeline) { nil }
let!(:head_pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
it 'returns status and data' do
- expect(subject[:status]).to eq(:parsed)
- expect(subject[:data]).to match_schema('entities/test_reports_comparer')
+ expect(comparison[:status]).to eq(:parsed)
+ expect(comparison[:data]).to match_schema('entities/test_reports_comparer')
end
end
@@ -24,8 +24,8 @@ RSpec.describe Ci::CompareTestReportsService do
let!(:head_pipeline) { create(:ci_pipeline, :with_test_reports, project: project) }
it 'returns status and data' do
- expect(subject[:status]).to eq(:parsed)
- expect(subject[:data]).to match_schema('entities/test_reports_comparer')
+ expect(comparison[:status]).to eq(:parsed)
+ expect(comparison[:data]).to match_schema('entities/test_reports_comparer')
end
end
@@ -39,9 +39,44 @@ RSpec.describe Ci::CompareTestReportsService do
end
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')
+ expect(comparison[:status]).to eq(:parsed)
+ expect(comparison.dig(:data, 'status')).to eq('success')
+ expect(comparison.dig(:data, 'suites', 0, 'status') ).to eq('error')
+ end
+ end
+
+ context 'test failure history' do
+ let!(:base_pipeline) { nil }
+ let!(:head_pipeline) { create(:ci_pipeline, :with_test_reports_with_three_failures, project: project) }
+
+ let(:new_failures) do
+ comparison.dig(:data, 'suites', 0, 'new_failures')
+ end
+
+ let(:recent_failures_per_test_case) do
+ new_failures.map { |f| f['recent_failures'] }
+ end
+
+ # Create test case failure records based on the head pipeline build
+ before do
+ stub_const("Gitlab::Ci::Reports::TestSuiteComparer::DEFAULT_MAX_TESTS", 2)
+ stub_const("Gitlab::Ci::Reports::TestSuiteComparer::DEFAULT_MIN_TESTS", 1)
+
+ build = head_pipeline.builds.last
+ build.update_column(:finished_at, 1.day.ago) # Just to be sure we are included in the report window
+
+ # The JUnit fixture for the given build has 3 failures.
+ # This service will create 1 test case failure record for each.
+ Ci::TestCasesService.new.execute(build)
+ end
+
+ it 'loads recent failures on limited test cases to avoid building up a huge DB query', :aggregate_failures do
+ expect(comparison[:data]).to match_schema('entities/test_reports_comparer')
+ expect(recent_failures_per_test_case).to eq([
+ { 'count' => 1, 'base_branch' => 'master' },
+ { 'count' => 1, 'base_branch' => 'master' }
+ ])
+ expect(new_failures.count).to eq(2)
end
end
end
diff --git a/spec/services/ci/create_downstream_pipeline_service_spec.rb b/spec/services/ci/create_downstream_pipeline_service_spec.rb
index 0cc380439a7..03cea4074bf 100644
--- a/spec/services/ci/create_downstream_pipeline_service_spec.rb
+++ b/spec/services/ci/create_downstream_pipeline_service_spec.rb
@@ -581,5 +581,40 @@ RSpec.describe Ci::CreateDownstreamPipelineService, '#execute' do
)
end
end
+
+ context 'when downstream pipeline has workflow rule' do
+ before do
+ stub_ci_pipeline_yaml_file(config)
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $my_var
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'when passing the required variable' do
+ before do
+ bridge.yaml_variables = [{ key: 'my_var', value: 'var', public: true }]
+ end
+
+ it 'creates the pipeline' do
+ expect { service.execute(bridge) }.to change(downstream_project.ci_pipelines, :count).by(1)
+
+ expect(bridge.reload).to be_success
+ end
+ end
+
+ context 'when not passing the required variable' do
+ it 'does not create the pipeline' do
+ expect { service.execute(bridge) }.not_to change(downstream_project.ci_pipelines, :count)
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/create_pipeline_service/cache_spec.rb b/spec/services/ci/create_pipeline_service/cache_spec.rb
index 1438c2e4aa0..5f74c2f1cef 100644
--- a/spec/services/ci/create_pipeline_service/cache_spec.rb
+++ b/spec/services/ci/create_pipeline_service/cache_spec.rb
@@ -4,13 +4,13 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
context 'cache' do
- let(:user) { create(:admin) }
+ let(:project) { create(:project, :custom_repo, files: files) }
+ let(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:source) { :push }
let(:service) { described_class.new(project, user, { ref: ref }) }
let(:pipeline) { service.execute(source) }
let(:job) { pipeline.builds.find_by(name: 'job') }
- let(:project) { create(:project, :custom_repo, files: files) }
before do
stub_ci_pipeline_yaml_file(config)
diff --git a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
index b5b3832ac00..9ccf289df7c 100644
--- a/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
+++ b/spec/services/ci/create_pipeline_service/creation_errors_and_warnings_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
describe 'creation errors and warnings' do
- let_it_be(:user) { create(:admin) }
- let_it_be(:project) { create(:project, :repository, creator: user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:source) { :push }
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 122870e0f3a..6320a16d646 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
@@ -3,7 +3,7 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
let_it_be(:project) { create(:project, :repository) }
- let_it_be(:user) { create(:admin) }
+ let_it_be(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:service) { described_class.new(project, user, { ref: ref }) }
diff --git a/spec/services/ci/create_pipeline_service/dry_run_spec.rb b/spec/services/ci/create_pipeline_service/dry_run_spec.rb
index 93378df80f0..60c56ed0f67 100644
--- a/spec/services/ci/create_pipeline_service/dry_run_spec.rb
+++ b/spec/services/ci/create_pipeline_service/dry_run_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
let_it_be(:project) { create(:project, :repository) }
- let_it_be(:user) { create(:admin) }
+ let_it_be(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:service) { described_class.new(project, user, { ref: ref }) }
diff --git a/spec/services/ci/create_pipeline_service/needs_spec.rb b/spec/services/ci/create_pipeline_service/needs_spec.rb
index 915dc46d664..512091035a2 100644
--- a/spec/services/ci/create_pipeline_service/needs_spec.rb
+++ b/spec/services/ci/create_pipeline_service/needs_spec.rb
@@ -4,8 +4,8 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
context 'needs' do
- let_it_be(:user) { create(:admin) }
- let_it_be(:project) { create(:project, :repository, creator: user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:source) { :push }
@@ -14,6 +14,7 @@ RSpec.describe Ci::CreatePipelineService do
before do
stub_ci_pipeline_yaml_file(config)
+ project.add_developer(user)
end
context 'with a valid config' do
diff --git a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
index f656ad52ac8..90b8baa23a7 100644
--- a/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
+++ b/spec/services/ci/create_pipeline_service/parameter_content_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
let_it_be(:project) { create(:project, :repository) }
- let_it_be(:user) { create(:admin) }
+ let_it_be(:user) { project.owner }
let(:service) { described_class.new(project, user, { ref: 'refs/heads/master' }) }
let(:content) do
<<~EOY
diff --git a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
index fb6cdf55be3..8df9b0c3e60 100644
--- a/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
+++ b/spec/services/ci/create_pipeline_service/parent_child_pipeline_spec.rb
@@ -279,6 +279,40 @@ RSpec.describe Ci::CreatePipelineService, '#execute' do
end
end
end
+
+ context 'when specifying multiple files' do
+ let(:config) do
+ <<~YAML
+ test:
+ script: rspec
+ deploy:
+ variables:
+ CROSS: downstream
+ stage: deploy
+ trigger:
+ include:
+ - project: my-namespace/my-project
+ file:
+ - 'path/to/child1.yml'
+ - 'path/to/child2.yml'
+ YAML
+ end
+
+ it_behaves_like 'successful creation' do
+ let(:expected_bridge_options) do
+ {
+ 'trigger' => {
+ 'include' => [
+ {
+ 'file' => ["path/to/child1.yml", "path/to/child2.yml"],
+ 'project' => 'my-namespace/my-project'
+ }
+ ]
+ }
+ }
+ end
+ end
+ end
end
end
diff --git a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
index 00a2dd74968..c84d9a53973 100644
--- a/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
+++ b/spec/services/ci/create_pipeline_service/pre_post_stages_spec.rb
@@ -3,8 +3,8 @@ require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
describe '.pre/.post stages' do
- let_it_be(:user) { create(:admin) }
- let_it_be(:project) { create(:project, :repository, creator: user) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { project.owner }
let(:source) { :push }
let(:service) { described_class.new(project, user, { ref: ref }) }
diff --git a/spec/services/ci/create_pipeline_service/rules_spec.rb b/spec/services/ci/create_pipeline_service/rules_spec.rb
index 1a1fa6e8f5d..a0ff2fff0ef 100644
--- a/spec/services/ci/create_pipeline_service/rules_spec.rb
+++ b/spec/services/ci/create_pipeline_service/rules_spec.rb
@@ -2,10 +2,10 @@
require 'spec_helper'
RSpec.describe Ci::CreatePipelineService do
- let(:user) { create(:admin) }
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.owner }
let(:ref) { 'refs/heads/master' }
let(:source) { :push }
- let(:project) { create(:project, :repository) }
let(:service) { described_class.new(project, user, { ref: ref }) }
let(:pipeline) { service.execute(source) }
let(:build_names) { pipeline.builds.pluck(:name) }
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index c28c3449485..f9015752644 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Ci::CreatePipelineService do
include ProjectForksHelper
let_it_be(:project, reload: true) { create(:project, :repository) }
- let(:user) { create(:admin) }
+ let_it_be(:user, reload: true) { project.owner }
let(:ref_name) { 'refs/heads/master' }
before do
@@ -41,7 +41,9 @@ RSpec.describe Ci::CreatePipelineService do
save_on_errors: save_on_errors,
trigger_request: trigger_request,
merge_request: merge_request,
- external_pull_request: external_pull_request)
+ external_pull_request: external_pull_request) do |pipeline|
+ yield(pipeline) if block_given?
+ end
end
# rubocop:enable Metrics/ParameterLists
@@ -153,6 +155,11 @@ RSpec.describe Ci::CreatePipelineService do
context 'when merge request target project is different from source project' do
let!(:project) { fork_project(target_project, nil, repository: true) }
let!(:target_project) { create(:project, :repository) }
+ let!(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ end
it 'updates head pipeline for merge request', :sidekiq_might_not_need_inline do
merge_request = create(:merge_request, source_branch: 'feature',
@@ -1440,6 +1447,11 @@ RSpec.describe Ci::CreatePipelineService do
let(:ref_name) { 'refs/heads/feature' }
let!(:project) { fork_project(target_project, nil, repository: true) }
let!(:target_project) { create(:project, :repository) }
+ let!(:user) { create(:user) }
+
+ before do
+ project.add_developer(user)
+ end
it 'creates a legacy detached merge request pipeline in the forked project', :sidekiq_might_not_need_inline do
expect(pipeline).to be_persisted
@@ -1858,6 +1870,12 @@ RSpec.describe Ci::CreatePipelineService do
- changes:
- README.md
allow_failure: true
+
+ README:
+ script: "I use variables for changes!"
+ rules:
+ - changes:
+ - $CI_JOB_NAME*
EOY
end
@@ -1867,10 +1885,10 @@ RSpec.describe Ci::CreatePipelineService do
.to receive(:modified_paths).and_return(%w[README.md])
end
- it 'creates two jobs' do
+ it 'creates five jobs' do
expect(pipeline).to be_persisted
expect(build_names)
- .to contain_exactly('regular-job', 'rules-job', 'delayed-job', 'negligible-job')
+ .to contain_exactly('regular-job', 'rules-job', 'delayed-job', 'negligible-job', 'README')
end
it 'sets when: for all jobs' do
@@ -2274,6 +2292,207 @@ RSpec.describe Ci::CreatePipelineService do
end
end
end
+
+ context 'with workflow rules with persisted variables' do
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $CI_COMMIT_REF_NAME == "master"
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+ end
+
+ context 'with no matches' do
+ let(:ref_name) { 'refs/heads/feature' }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+
+ context 'with workflow rules with pipeline variables' do
+ let(:pipeline) do
+ execute_service(variables_attributes: variables_attributes)
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ let(:variables_attributes) do
+ [{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+ end
+
+ context 'with no matches' do
+ let(:variables_attributes) { {} }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+
+ context 'with workflow rules with trigger variables' do
+ let(:pipeline) do
+ execute_service do |pipeline|
+ pipeline.variables.build(variables)
+ end
+ end
+
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ regular-job:
+ script: 'echo Hello, World!'
+ EOY
+ end
+
+ context 'with matches' do
+ let(:variables) do
+ [{ key: 'SOME_VARIABLE', secret_value: 'SOME_VAR' }]
+ end
+
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('regular-job')
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'does not a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+
+ context 'when a job requires the same variable' do
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ build:
+ stage: build
+ script: 'echo build'
+ rules:
+ - if: $SOME_VARIABLE
+
+ test1:
+ stage: test
+ script: 'echo test1'
+ needs: [build]
+
+ test2:
+ stage: test
+ script: 'echo test2'
+ EOY
+ end
+
+ it 'creates a pipeline' do
+ expect(pipeline).to be_persisted
+ expect(build_names).to contain_exactly('build', 'test1', 'test2')
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'does not a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+ end
+
+ context 'with no matches' do
+ let(:variables) { {} }
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+
+ context 'when a job requires the same variable' do
+ let(:config) do
+ <<-EOY
+ workflow:
+ rules:
+ - if: $SOME_VARIABLE
+
+ build:
+ stage: build
+ script: 'echo build'
+ rules:
+ - if: $SOME_VARIABLE
+
+ test1:
+ stage: test
+ script: 'echo test1'
+ needs: [build]
+
+ test2:
+ stage: test
+ script: 'echo test2'
+ EOY
+ end
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+
+ context 'when FF ci_seed_block_run_before_workflow_rules is disabled' do
+ before do
+ stub_feature_flags(ci_seed_block_run_before_workflow_rules: false)
+ end
+
+ it 'does not create a pipeline' do
+ expect(pipeline).not_to be_persisted
+ end
+ end
+ end
+ end
+ end
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
index f196afb05e8..e54f10cc4f4 100644
--- a/spec/services/ci/daily_build_group_report_result_service_spec.rb
+++ b/spec/services/ci/daily_build_group_report_result_service_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
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) }
+ let(:coverages) { Ci::DailyBuildGroupReportResult.all }
it 'creates daily code coverage record for each job in the pipeline that has coverage value' do
described_class.new.execute(pipeline)
@@ -158,4 +159,30 @@ RSpec.describe Ci::DailyBuildGroupReportResultService, '#execute' do
expect { described_class.new.execute(new_pipeline) }.not_to raise_error
end
end
+
+ context 'when pipeline ref_path is the project default branch' do
+ let(:default_branch) { 'master' }
+
+ before do
+ allow(pipeline.project).to receive(:default_branch).and_return(default_branch)
+ end
+
+ it 'sets default branch to true' do
+ described_class.new.execute(pipeline)
+
+ coverages.each do |coverage|
+ expect(coverage.default_branch).to be_truthy
+ end
+ end
+ end
+
+ context 'when pipeline ref_path is not the project default branch' do
+ it 'sets default branch to false' do
+ described_class.new.execute(pipeline)
+
+ coverages.each do |coverage|
+ expect(coverage.default_branch).to be_falsey
+ end
+ 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 3d5329811ad..c8d426ee657 100644
--- a/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
+++ b/spec/services/ci/destroy_expired_job_artifacts_service_spec.rb
@@ -5,26 +5,85 @@ require 'spec_helper'
RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared_state do
include ExclusiveLeaseHelpers
+ let(:service) { described_class.new }
+
describe '.execute' do
subject { service.execute }
- let(:service) { described_class.new }
-
- let_it_be(:artifact) { create(:ci_job_artifact, expire_at: 1.day.ago) }
+ let_it_be(:artifact, reload: true) do
+ create(:ci_job_artifact, expire_at: 1.day.ago)
+ end
before(:all) do
artifact.job.pipeline.unlocked!
end
context 'when artifact is expired' do
- context 'when artifact is not locked' do
+ context 'with preloaded relationships' do
before do
- artifact.job.pipeline.unlocked!
+ job = create(:ci_build, pipeline: artifact.job.pipeline)
+ create(:ci_job_artifact, :archive, :expired, job: job)
+
+ stub_const('Ci::DestroyExpiredJobArtifactsService::LOOP_LIMIT', 1)
end
- it 'destroys job artifact' do
+ it 'performs the smallest number of queries for job_artifacts' do
+ log = ActiveRecord::QueryRecorder.new { subject }
+
+ # SELECT expired ci_job_artifacts
+ # PRELOAD projects, routes, project_statistics
+ # BEGIN
+ # INSERT into ci_deleted_objects
+ # DELETE loaded ci_job_artifacts
+ # DELETE security_findings -- for EE
+ # COMMIT
+ expect(log.count).to be_within(1).of(8)
+ end
+ end
+
+ context 'when artifact is not locked' do
+ it 'deletes job artifact record' do
expect { subject }.to change { Ci::JobArtifact.count }.by(-1)
end
+
+ context 'when the artifact does not a file attached to it' do
+ it 'does not create deleted objects' do
+ expect(artifact.exists?).to be_falsy # sanity check
+
+ expect { subject }.not_to change { Ci::DeletedObject.count }
+ end
+ end
+
+ context 'when the artifact has a file attached to it' do
+ before do
+ artifact.file = fixture_file_upload(Rails.root.join('spec/fixtures/ci_build_artifacts.zip'), 'application/zip')
+ artifact.save!
+ end
+
+ it 'creates a deleted object' do
+ expect { subject }.to change { Ci::DeletedObject.count }.by(1)
+ end
+
+ it 'resets project statistics' do
+ expect(ProjectStatistics).to receive(:increment_statistic).once
+ .with(artifact.project, :build_artifacts_size, -artifact.file.size)
+ .and_call_original
+
+ subject
+ end
+
+ it 'does not remove the files' do
+ expect { subject }.not_to change { artifact.file.exists? }
+ end
+
+ it 'reports metrics for destroyed artifacts' do
+ counter = service.send(:destroyed_artifacts_counter)
+
+ expect(counter).to receive(:increment).with({}, 1).and_call_original
+
+ subject
+ end
+ end
end
context 'when artifact is locked' do
@@ -61,14 +120,34 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
context 'when failed to destroy artifact' do
before do
stub_const('Ci::DestroyExpiredJobArtifactsService::LOOP_LIMIT', 10)
+ end
- allow_any_instance_of(Ci::JobArtifact)
- .to receive(:destroy!)
- .and_raise(ActiveRecord::RecordNotDestroyed)
+ context 'when the import fails' do
+ before do
+ expect(Ci::DeletedObject)
+ .to receive(:bulk_import)
+ .once
+ .and_raise(ActiveRecord::RecordNotDestroyed)
+ end
+
+ it 'raises an exception and stop destroying' do
+ expect { subject }.to raise_error(ActiveRecord::RecordNotDestroyed)
+ .and not_change { Ci::JobArtifact.count }.from(1)
+ end
end
- it 'raises an exception and stop destroying' do
- expect { subject }.to raise_error(ActiveRecord::RecordNotDestroyed)
+ context 'when the delete fails' do
+ before do
+ expect(Ci::JobArtifact)
+ .to receive(:id_in)
+ .once
+ .and_raise(ActiveRecord::RecordNotDestroyed)
+ end
+
+ it 'raises an exception rolls back the insert' do
+ expect { subject }.to raise_error(ActiveRecord::RecordNotDestroyed)
+ .and not_change { Ci::DeletedObject.count }.from(0)
+ end
end
end
@@ -85,7 +164,7 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
context 'when timeout happens' do
before do
stub_const('Ci::DestroyExpiredJobArtifactsService::LOOP_TIMEOUT', 1.second)
- allow_any_instance_of(described_class).to receive(:destroy_batch) { true }
+ allow_any_instance_of(described_class).to receive(:destroy_artifacts_batch) { true }
end
it 'returns false and does not continue destroying' do
@@ -176,4 +255,16 @@ RSpec.describe Ci::DestroyExpiredJobArtifactsService, :clean_gitlab_redis_shared
end
end
end
+
+ describe '.destroy_job_artifacts_batch' do
+ it 'returns a falsy value without artifacts' do
+ expect(service.send(:destroy_job_artifacts_batch)).to be_falsy
+ end
+ end
+
+ describe '.destroy_pipeline_artifacts_batch' do
+ it 'returns a falsy value without artifacts' do
+ expect(service.send(:destroy_pipeline_artifacts_batch)).to be_falsy
+ end
+ end
end
diff --git a/spec/services/ci/list_config_variables_service_spec.rb b/spec/services/ci/list_config_variables_service_spec.rb
index 5cc0481768b..95c98c2b5ef 100644
--- a/spec/services/ci/list_config_variables_service_spec.rb
+++ b/spec/services/ci/list_config_variables_service_spec.rb
@@ -3,8 +3,9 @@
require 'spec_helper'
RSpec.describe Ci::ListConfigVariablesService do
- let_it_be(:project) { create(:project, :repository) }
- let(:service) { described_class.new(project) }
+ let(:project) { create(:project, :repository) }
+ let(:user) { project.creator }
+ let(:service) { described_class.new(project, user) }
let(:result) { YAML.dump(ci_config) }
subject { service.execute(sha) }
@@ -38,6 +39,40 @@ RSpec.describe Ci::ListConfigVariablesService do
end
end
+ context 'when config has includes' do
+ let(:sha) { 'master' }
+ let(:ci_config) do
+ {
+ include: [{ local: 'other_file.yml' }],
+ variables: {
+ KEY1: { value: 'val 1', description: 'description 1' }
+ },
+ test: {
+ stage: 'test',
+ script: 'echo'
+ }
+ }
+ end
+
+ before do
+ allow_next_instance_of(Repository) do |repository|
+ allow(repository).to receive(:blob_data_at).with(sha, 'other_file.yml') do
+ <<~HEREDOC
+ variables:
+ KEY2:
+ value: 'val 2'
+ description: 'description 2'
+ HEREDOC
+ end
+ end
+ end
+
+ it 'returns variable list' do
+ expect(subject['KEY1']).to eq({ value: 'val 1', description: 'description 1' })
+ expect(subject['KEY2']).to eq({ value: 'val 2', description: 'description 2' })
+ end
+ end
+
context 'when sending an invalid sha' do
let(:sha) { 'invalid-sha' }
let(:ci_config) { nil }
diff --git a/spec/services/ci/test_cases_service_spec.rb b/spec/services/ci/test_cases_service_spec.rb
new file mode 100644
index 00000000000..b61d308640f
--- /dev/null
+++ b/spec/services/ci/test_cases_service_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ci::TestCasesService, :aggregate_failures do
+ describe '#execute' do
+ subject(:execute_service) { described_class.new.execute(build) }
+
+ context 'when build has test reports' do
+ let(:build) { create(:ci_build, :success, :test_reports) } # The test report has 2 test case failures
+
+ it 'creates test case failures records' do
+ execute_service
+
+ expect(Ci::TestCase.count).to eq(2)
+ expect(Ci::TestCaseFailure.count).to eq(2)
+ end
+
+ context 'when feature flag for test failure history is disabled' do
+ before do
+ stub_feature_flags(test_failure_history: false)
+ end
+
+ it 'does not persist data' do
+ execute_service
+
+ expect(Ci::TestCase.count).to eq(0)
+ expect(Ci::TestCaseFailure.count).to eq(0)
+ end
+ end
+
+ context 'when build is not for the default branch' do
+ before do
+ build.update_column(:ref, 'new-feature')
+ end
+
+ it 'does not persist data' do
+ execute_service
+
+ expect(Ci::TestCase.count).to eq(0)
+ expect(Ci::TestCaseFailure.count).to eq(0)
+ end
+ end
+
+ context 'when test failure data have already been persisted with the same exact attributes' do
+ before do
+ execute_service
+ end
+
+ it 'does not fail but does not persist new data' do
+ expect { described_class.new.execute(build) }.not_to raise_error
+
+ expect(Ci::TestCase.count).to eq(2)
+ expect(Ci::TestCaseFailure.count).to eq(2)
+ end
+ end
+
+ context 'when test failure data have duplicates within the same payload (happens when the JUnit report has duplicate test case names but have different failures)' do
+ let(:build) { create(:ci_build, :success, :test_reports_with_duplicate_failed_test_names) } # The test report has 2 test case failures but with the same test case keys
+
+ it 'does not fail but does not persist duplicate data' do
+ expect { described_class.new.execute(build) }.not_to raise_error
+
+ expect(Ci::TestCase.count).to eq(1)
+ expect(Ci::TestCaseFailure.count).to eq(1)
+ end
+ end
+
+ context 'when number of failed test cases exceed the limit' do
+ before do
+ stub_const("#{described_class.name}::MAX_TRACKABLE_FAILURES", 1)
+ end
+
+ it 'does not persist data' do
+ execute_service
+
+ expect(Ci::TestCase.count).to eq(0)
+ expect(Ci::TestCaseFailure.count).to eq(0)
+ end
+ end
+ end
+
+ context 'when build has no test reports' do
+ let(:build) { create(:ci_build, :running) }
+
+ it 'does not persist data' do
+ execute_service
+
+ expect(Ci::TestCase.count).to eq(0)
+ expect(Ci::TestCaseFailure.count).to eq(0)
+ end
+ end
+ end
+end
diff --git a/spec/services/clusters/applications/check_installation_progress_service_spec.rb b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
index 13f7cd62002..698804ff6af 100644
--- a/spec/services/clusters/applications/check_installation_progress_service_spec.rb
+++ b/spec/services/clusters/applications/check_installation_progress_service_spec.rb
@@ -161,10 +161,10 @@ RSpec.describe Clusters::Applications::CheckInstallationProgressService, '#execu
expect(application.status_reason).to be_nil
end
- it 'tracks application install' do
- expect(Gitlab::Tracking).to receive(:event).with('cluster:applications', "cluster_application_helm_installed")
-
+ it 'tracks application install', :snowplow do
service.execute
+
+ expect_snowplow_event(category: 'cluster:applications', action: 'cluster_application_helm_installed')
end
end
diff --git a/spec/services/clusters/applications/uninstall_service_spec.rb b/spec/services/clusters/applications/uninstall_service_spec.rb
index 50d7e82c47e..bfe38ba670d 100644
--- a/spec/services/clusters/applications/uninstall_service_spec.rb
+++ b/spec/services/clusters/applications/uninstall_service_spec.rb
@@ -14,7 +14,7 @@ RSpec.describe Clusters::Applications::UninstallService, '#execute' do
context 'when there are no errors' do
before do
- expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand))
+ expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand))
allow(worker_class).to receive(:perform_in).and_return(nil)
end
@@ -36,7 +36,7 @@ RSpec.describe Clusters::Applications::UninstallService, '#execute' do
let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) }
before do
- expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error)
+ expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand)).and_raise(error)
end
include_examples 'logs kubernetes errors' do
@@ -58,7 +58,7 @@ RSpec.describe Clusters::Applications::UninstallService, '#execute' do
let(:error) { StandardError.new('something bad happened') }
before do
- expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::DeleteCommand)).and_raise(error)
+ expect(helm_client).to receive(:uninstall).with(kind_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand)).and_raise(error)
end
include_examples 'logs kubernetes errors' do
diff --git a/spec/services/clusters/aws/authorize_role_service_spec.rb b/spec/services/clusters/aws/authorize_role_service_spec.rb
index 5b47cf0ecde..302bae6e3ff 100644
--- a/spec/services/clusters/aws/authorize_role_service_spec.rb
+++ b/spec/services/clusters/aws/authorize_role_service_spec.rb
@@ -11,14 +11,16 @@ RSpec.describe Clusters::Aws::AuthorizeRoleService do
let(:credentials_service) { instance_double(Clusters::Aws::FetchCredentialsService, execute: credentials) }
let(:role_arn) { 'arn:my-role' }
+ let(:region) { 'region' }
let(:params) do
params = ActionController::Parameters.new({
cluster: {
- role_arn: role_arn
+ role_arn: role_arn,
+ region: region
}
})
- params.require(:cluster).permit(:role_arn)
+ params.require(:cluster).permit(:role_arn, :region)
end
before do
diff --git a/spec/services/clusters/aws/fetch_credentials_service_spec.rb b/spec/services/clusters/aws/fetch_credentials_service_spec.rb
index a0e63d96a5c..361a947f634 100644
--- a/spec/services/clusters/aws/fetch_credentials_service_spec.rb
+++ b/spec/services/clusters/aws/fetch_credentials_service_spec.rb
@@ -19,7 +19,7 @@ RSpec.describe Clusters::Aws::FetchCredentialsService do
subject { described_class.new(provision_role, provider: provider).execute }
context 'provision role is configured' do
- let(:provision_role) { create(:aws_role, user: user) }
+ let(:provision_role) { create(:aws_role, user: user, region: 'custom-region') }
before do
stub_application_setting(eks_access_key_id: gitlab_access_key_id)
@@ -53,10 +53,12 @@ RSpec.describe Clusters::Aws::FetchCredentialsService do
context 'provider is not specifed' do
let(:provider) { nil }
- let(:region) { Clusters::Providers::Aws::DEFAULT_REGION }
+ let(:region) { provision_role.region }
let(:session_name) { "gitlab-eks-autofill-user-#{user.id}" }
let(:session_policy) { 'policy-document' }
+ subject { described_class.new(provision_role, provider: provider).execute }
+
before do
allow(File).to receive(:read)
.with(Rails.root.join('vendor', 'aws', 'iam', 'eks_cluster_read_only_policy.json'))
@@ -64,6 +66,13 @@ RSpec.describe Clusters::Aws::FetchCredentialsService do
end
it { is_expected.to eq assumed_role_credentials }
+
+ context 'region is not specifed' do
+ let(:region) { Clusters::Providers::Aws::DEFAULT_REGION }
+ let(:provision_role) { create(:aws_role, user: user, region: nil) }
+
+ it { is_expected.to eq assumed_role_credentials }
+ end
end
end
diff --git a/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb b/spec/services/clusters/kubernetes/create_or_update_namespace_service_spec.rb
index 7e3f1fdb379..90956e7b4ea 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,6 +28,7 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateNamespaceService, '#execute'
stub_kubeclient_get_secret_error(api_url, 'gitlab-token')
stub_kubeclient_create_secret(api_url)
+ stub_kubeclient_delete_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 257e2e53733..a4f018aec0c 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
@@ -141,6 +141,7 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
before do
cluster.platform_kubernetes.rbac!
+ stub_kubeclient_delete_role_binding(api_url, role_binding_name, 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)
@@ -160,60 +161,26 @@ RSpec.describe Clusters::Kubernetes::CreateOrUpdateServiceAccountService do
it_behaves_like 'creates service account and token'
- context 'kubernetes_cluster_namespace_role_admin FF is enabled' do
- before do
- stub_feature_flags(kubernetes_cluster_namespace_role_admin: true)
- end
-
- it 'creates a namespaced role binding with admin access' do
- subject
-
- expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{role_binding_name}").with(
- body: hash_including(
- metadata: { name: "gitlab-#{namespace}", namespace: "#{namespace}" },
- roleRef: {
- apiGroup: 'rbac.authorization.k8s.io',
- kind: 'ClusterRole',
- name: 'admin'
- },
- subjects: [
- {
- kind: 'ServiceAccount',
- name: service_account_name,
- namespace: namespace
- }
- ]
- )
- )
- end
- end
+ it 'creates a namespaced role binding with admin access' do
+ subject
- context 'kubernetes_cluster_namespace_role_admin FF is disabled' do
- before do
- stub_feature_flags(kubernetes_cluster_namespace_role_admin: false)
- end
-
- it 'creates a namespaced role binding with edit access' do
- subject
-
- expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{role_binding_name}").with(
- body: hash_including(
- metadata: { name: "gitlab-#{namespace}", namespace: "#{namespace}" },
- roleRef: {
- apiGroup: 'rbac.authorization.k8s.io',
- kind: 'ClusterRole',
- name: 'edit'
- },
- subjects: [
- {
- kind: 'ServiceAccount',
- name: service_account_name,
- namespace: namespace
- }
- ]
- )
+ expect(WebMock).to have_requested(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{role_binding_name}").with(
+ body: hash_including(
+ metadata: { name: "gitlab-#{namespace}", namespace: "#{namespace}" },
+ roleRef: {
+ apiGroup: 'rbac.authorization.k8s.io',
+ kind: 'ClusterRole',
+ name: 'admin'
+ },
+ subjects: [
+ {
+ kind: 'ServiceAccount',
+ name: service_account_name,
+ namespace: namespace
+ }
+ ]
)
- end
+ )
end
it 'creates a role binding granting crossplane database permissions to the service account' do
diff --git a/spec/services/clusters/update_service_spec.rb b/spec/services/clusters/update_service_spec.rb
index e496ccd5c23..9aead97f41c 100644
--- a/spec/services/clusters/update_service_spec.rb
+++ b/spec/services/clusters/update_service_spec.rb
@@ -197,7 +197,7 @@ RSpec.describe Clusters::UpdateService do
context 'manangement_project is outside of the namespace scope' do
before do
- management_project.update(group: create(:group))
+ management_project.update!(group: create(:group))
end
let(:params) do
@@ -224,7 +224,7 @@ RSpec.describe Clusters::UpdateService do
context 'manangement_project is outside of the namespace scope' do
before do
- management_project.update(group: create(:group))
+ management_project.update!(group: create(:group))
end
let(:params) do
diff --git a/spec/services/container_expiration_policies/cleanup_service_spec.rb b/spec/services/container_expiration_policies/cleanup_service_spec.rb
new file mode 100644
index 00000000000..2da35cfc3fb
--- /dev/null
+++ b/spec/services/container_expiration_policies/cleanup_service_spec.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerExpirationPolicies::CleanupService do
+ let_it_be(:repository, reload: true) { create(:container_repository) }
+ let_it_be(:project) { repository.project }
+
+ let(:service) { described_class.new(repository) }
+
+ describe '#execute' do
+ subject { service.execute }
+
+ context 'with a successful cleanup tags service execution' do
+ let(:cleanup_tags_service_params) { project.container_expiration_policy.policy_params.merge('container_expiration_policy' => true) }
+ let(:cleanup_tags_service) { instance_double(Projects::ContainerRepository::CleanupTagsService) }
+
+ it 'completely clean up the repository' do
+ expect(Projects::ContainerRepository::CleanupTagsService)
+ .to receive(:new).with(project, nil, cleanup_tags_service_params).and_return(cleanup_tags_service)
+ expect(cleanup_tags_service).to receive(:execute).with(repository).and_return(status: :success)
+
+ response = subject
+
+ aggregate_failures "checking the response and container repositories" do
+ expect(response.success?).to eq(true)
+ expect(response.payload).to include(cleanup_status: :finished, container_repository_id: repository.id)
+ expect(ContainerRepository.waiting_for_cleanup.count).to eq(0)
+ expect(repository.reload.cleanup_unscheduled?).to be_truthy
+ expect(repository.expiration_policy_started_at).to eq(nil)
+ end
+ end
+ end
+
+ context 'without a successful cleanup tags service execution' do
+ it 'partially clean up the repository' do
+ expect(Projects::ContainerRepository::CleanupTagsService)
+ .to receive(:new).and_return(double(execute: { status: :error, message: 'timeout' }))
+
+ response = subject
+
+ aggregate_failures "checking the response and container repositories" do
+ expect(response.success?).to eq(true)
+ expect(response.payload).to include(cleanup_status: :unfinished, container_repository_id: repository.id)
+ expect(ContainerRepository.waiting_for_cleanup.count).to eq(1)
+ expect(repository.reload.cleanup_unfinished?).to be_truthy
+ expect(repository.expiration_policy_started_at).not_to eq(nil)
+ end
+ end
+ end
+
+ context 'with no repository' do
+ let(:service) { described_class.new(nil) }
+
+ it 'returns an error response' do
+ response = subject
+
+ expect(response.success?).to eq(false)
+ end
+ end
+ end
+end
diff --git a/spec/services/container_expiration_policy_service_spec.rb b/spec/services/container_expiration_policy_service_spec.rb
index dfce51d73ad..4294e6b3f06 100644
--- a/spec/services/container_expiration_policy_service_spec.rb
+++ b/spec/services/container_expiration_policy_service_spec.rb
@@ -27,20 +27,5 @@ RSpec.describe ContainerExpirationPolicyService do
expect(container_expiration_policy.next_run_at).to be > Time.zone.now
end
-
- context 'with an invalid container expiration policy' do
- before do
- allow(container_expiration_policy).to receive(:valid?).and_return(false)
- end
-
- it 'disables it' do
- expect(container_expiration_policy).not_to receive(:schedule_next_run!)
- expect(CleanupContainerRepositoryWorker).not_to receive(:perform_async)
-
- expect { subject }
- .to change { container_expiration_policy.reload.enabled }.from(true).to(false)
- .and raise_error(ContainerExpirationPolicyService::InvalidPolicyError)
- end
- end
end
end
diff --git a/spec/services/dependency_proxy/download_blob_service_spec.rb b/spec/services/dependency_proxy/download_blob_service_spec.rb
new file mode 100644
index 00000000000..4b5c6b5bd6a
--- /dev/null
+++ b/spec/services/dependency_proxy/download_blob_service_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::DownloadBlobService do
+ include DependencyProxyHelpers
+
+ let(:image) { 'alpine' }
+ let(:token) { Digest::SHA256.hexdigest('123') }
+ let(:blob_sha) { Digest::SHA256.hexdigest('ruby:2.7.0') }
+
+ subject { described_class.new(image, blob_sha, token).execute }
+
+ context 'remote request is successful' do
+ before do
+ stub_blob_download(image, blob_sha)
+ end
+
+ it { expect(subject[:status]).to eq(:success) }
+ it { expect(subject[:file]).to be_a(Tempfile) }
+ it { expect(subject[:file].size).to eq(6) }
+ end
+
+ context 'remote request is not found' do
+ before do
+ stub_blob_download(image, blob_sha, 404)
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(404) }
+ it { expect(subject[:message]).to eq('Non-success response code on downloading blob fragment') }
+ end
+
+ context 'net timeout exception' do
+ before do
+ blob_url = DependencyProxy::Registry.blob_url(image, blob_sha)
+
+ stub_full_request(blob_url).to_timeout
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(599) }
+ it { expect(subject[:message]).to eq('execution expired') }
+ end
+end
diff --git a/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb
new file mode 100644
index 00000000000..4ba53d49d38
--- /dev/null
+++ b/spec/services/dependency_proxy/find_or_create_blob_service_spec.rb
@@ -0,0 +1,53 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::FindOrCreateBlobService do
+ include DependencyProxyHelpers
+
+ let(:blob) { create(:dependency_proxy_blob) }
+ let(:group) { blob.group }
+ let(:image) { 'alpine' }
+ let(:tag) { '3.9' }
+ let(:token) { Digest::SHA256.hexdigest('123') }
+ let(:blob_sha) { '40bd001563085fc35165329ea1ff5c5ecbdbbeef' }
+
+ subject { described_class.new(group, image, token, blob_sha).execute }
+
+ before do
+ stub_registry_auth(image, token)
+ end
+
+ context 'no cache' do
+ before do
+ stub_blob_download(image, blob_sha)
+ end
+
+ it 'downloads blob from remote registry if there is no cached one' do
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:blob]).to be_a(DependencyProxy::Blob)
+ expect(subject[:blob]).to be_persisted
+ end
+ end
+
+ context 'cached blob' do
+ let(:blob_sha) { blob.file_name.sub('.gz', '') }
+
+ it 'uses cached blob instead of downloading one' do
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:blob]).to be_a(DependencyProxy::Blob)
+ expect(subject[:blob]).to eq(blob)
+ end
+ end
+
+ context 'no such blob exists remotely' do
+ before do
+ stub_blob_download(image, blob_sha, 404)
+ end
+
+ it 'returns error message and http status' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to eq('Failed to download the blob')
+ expect(subject[:http_status]).to eq(404)
+ end
+ end
+end
diff --git a/spec/services/dependency_proxy/pull_manifest_service_spec.rb b/spec/services/dependency_proxy/pull_manifest_service_spec.rb
new file mode 100644
index 00000000000..030ed9c001b
--- /dev/null
+++ b/spec/services/dependency_proxy/pull_manifest_service_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::PullManifestService do
+ include DependencyProxyHelpers
+
+ let(:image) { 'alpine' }
+ let(:tag) { '3.9' }
+ let(:token) { Digest::SHA256.hexdigest('123') }
+ let(:manifest) { { foo: 'bar' }.to_json }
+
+ subject { described_class.new(image, tag, token).execute }
+
+ context 'remote request is successful' do
+ before do
+ stub_manifest_download(image, tag)
+ end
+
+ it { expect(subject[:status]).to eq(:success) }
+ it { expect(subject[:manifest]).to eq(manifest) }
+ end
+
+ context 'remote request is not found' do
+ before do
+ stub_manifest_download(image, tag, 404, 'Not found')
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(404) }
+ it { expect(subject[:message]).to eq('Not found') }
+ end
+
+ context 'net timeout exception' do
+ before do
+ manifest_link = DependencyProxy::Registry.manifest_url(image, tag)
+
+ stub_full_request(manifest_link).to_timeout
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(599) }
+ it { expect(subject[:message]).to eq('execution expired') }
+ end
+end
diff --git a/spec/services/dependency_proxy/request_token_service_spec.rb b/spec/services/dependency_proxy/request_token_service_spec.rb
new file mode 100644
index 00000000000..8b3ba783b8d
--- /dev/null
+++ b/spec/services/dependency_proxy/request_token_service_spec.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::RequestTokenService do
+ include DependencyProxyHelpers
+
+ let(:image) { 'alpine:3.9' }
+ let(:token) { Digest::SHA256.hexdigest('123') }
+
+ subject { described_class.new(image).execute }
+
+ context 'remote request is successful' do
+ before do
+ stub_registry_auth(image, token)
+ end
+
+ it { expect(subject[:status]).to eq(:success) }
+ it { expect(subject[:token]).to eq(token) }
+ end
+
+ context 'remote request is not found' do
+ before do
+ stub_registry_auth(image, token, 404)
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(404) }
+ it { expect(subject[:message]).to eq('Expected 200 response code for an access token') }
+ end
+
+ context 'failed to parse response body' do
+ before do
+ stub_registry_auth(image, token, 200, 'dasd1321: wow')
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(500) }
+ it { expect(subject[:message]).to eq('Failed to parse a response body for an access token') }
+ end
+
+ context 'net timeout exception' do
+ before do
+ auth_link = DependencyProxy::Registry.auth_url(image)
+
+ stub_full_request(auth_link, method: :any).to_timeout
+ end
+
+ it { expect(subject[:status]).to eq(:error) }
+ it { expect(subject[:http_status]).to eq(599) }
+ it { expect(subject[:message]).to eq('execution expired') }
+ end
+end
diff --git a/spec/services/deploy_keys/collect_keys_service_spec.rb b/spec/services/deploy_keys/collect_keys_service_spec.rb
deleted file mode 100644
index 3442e5e456a..00000000000
--- a/spec/services/deploy_keys/collect_keys_service_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe DeployKeys::CollectKeysService do
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :private) }
-
- subject { DeployKeys::CollectKeysService.new(project, user) }
-
- before do
- project&.add_developer(user)
- end
-
- context 'when no project is passed in' do
- let(:project) { nil }
-
- it 'returns an empty Array' do
- expect(subject.execute).to be_empty
- end
- end
-
- context 'when no user is passed in' do
- let(:user) { nil }
-
- it 'returns an empty Array' do
- expect(subject.execute).to be_empty
- end
- end
-
- context 'when a project is passed in' do
- let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project) }
- let_it_be(:deploy_key) { deploy_keys_project.deploy_key }
-
- it 'only returns deploy keys with write access' do
- create(:deploy_keys_project, project: project)
-
- expect(subject.execute).to contain_exactly(deploy_key)
- end
-
- it 'returns deploy keys only for this project' do
- other_project = create(:project)
- create(:deploy_keys_project, :write_access, project: other_project)
-
- expect(subject.execute).to contain_exactly(deploy_key)
- end
- end
-
- context 'when the user cannot read the project' do
- before do
- project.members.delete_all
- end
-
- it 'returns an empty Array' do
- expect(subject.execute).to be_empty
- end
- end
-end
diff --git a/spec/services/design_management/copy_design_collection/copy_service_spec.rb b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
index e93e5f13fea..ddbed91815f 100644
--- a/spec/services/design_management/copy_design_collection/copy_service_spec.rb
+++ b/spec/services/design_management/copy_design_collection/copy_service_spec.rb
@@ -68,6 +68,31 @@ RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitla
include_examples 'service error', message: 'Target design collection already has designs'
end
+ context 'when target project already has designs' do
+ let!(:issue_x) { create(:issue, project: target_issue.project) }
+ let!(:existing) { create(:design, issue: issue_x, project: target_issue.project) }
+
+ let(:new_designs) do
+ target_issue.reset
+ target_issue.designs.where.not(id: existing.id)
+ end
+
+ it 'sets IIDs for new designs above existing ones' do
+ subject
+
+ expect(new_designs).to all(have_attributes(iid: (be > existing.iid)))
+ end
+
+ it 'does not allow for IID collisions' do
+ subject
+ create(:design, issue: issue_x, project: target_issue.project)
+
+ design_iids = target_issue.project.designs.map(&:id)
+
+ expect(design_iids).to match_array(design_iids.uniq)
+ end
+ end
+
include_examples 'service success'
it 'creates a design repository for the target project' do
@@ -162,9 +187,7 @@ RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitla
it 'copies the Git repository data', :aggregate_failures do
subject
- commit_shas = target_repository.commits('master', limit: 99).map(&:id)
-
- expect(commit_shas).to include(*target_issue.design_versions.ordered.pluck(:sha))
+ expect(commits_on_master(limit: 99)).to include(*target_issue.design_versions.ordered.pluck(:sha))
end
it 'creates a master branch if none previously existed' do
@@ -212,9 +235,7 @@ RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitla
issue_2 = create(:issue, project: target_issue.project)
create(:design, :with_file, issue: issue_2, project: target_issue.project)
- expect { subject }.not_to change {
- expect(target_repository.commits('master', limit: 10).size).to eq(1)
- }
+ expect { subject }.not_to change { commits_on_master }
end
it 'sets the design collection copy state' do
@@ -223,6 +244,10 @@ RSpec.describe DesignManagement::CopyDesignCollection::CopyService, :clean_gitla
expect(target_issue.design_collection).to be_copy_error
end
end
+
+ def commits_on_master(limit: 10)
+ target_repository.commits('master', limit: limit).map(&:id)
+ end
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
index 749030af97d..e06b6fbf116 100644
--- a/spec/services/design_management/generate_image_versions_service_spec.rb
+++ b/spec/services/design_management/generate_image_versions_service_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe DesignManagement::GenerateImageVersionsService do
end
it 'logs if the raw image cannot be found' do
- version.designs.first.update(filename: 'foo.png')
+ version.designs.first.update!(filename: 'foo.png')
expect(Gitlab::AppLogger).to receive(:error).with("No design file found for Action: #{action.id}")
diff --git a/spec/services/discussions/resolve_service_spec.rb b/spec/services/discussions/resolve_service_spec.rb
index 5ff0d535b46..42c4ef52741 100644
--- a/spec/services/discussions/resolve_service_spec.rb
+++ b/spec/services/discussions/resolve_service_spec.rb
@@ -40,11 +40,11 @@ RSpec.describe Discussions::ResolveService do
context 'with a project that requires all discussion to be resolved' do
before do
- project.update(only_allow_merge_if_all_discussions_are_resolved: true)
+ project.update!(only_allow_merge_if_all_discussions_are_resolved: true)
end
after do
- project.update(only_allow_merge_if_all_discussions_are_resolved: false)
+ project.update!(only_allow_merge_if_all_discussions_are_resolved: false)
end
let_it_be(:other_discussion) { create(:diff_note_on_merge_request, noteable: merge_request, project: project).to_discussion }
diff --git a/spec/services/draft_notes/destroy_service_spec.rb b/spec/services/draft_notes/destroy_service_spec.rb
index f725f08f3c7..1f246a56eb3 100644
--- a/spec/services/draft_notes/destroy_service_spec.rb
+++ b/spec/services/draft_notes/destroy_service_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe DraftNotes::DestroyService do
it 'destroys all draft notes for a user in a merge request' do
create_list(:draft_note, 2, merge_request: merge_request, author: user)
- expect { destroy }.to change { DraftNote.count }.by(-2)
+ expect { destroy }.to change { DraftNote.count }.by(-2) # rubocop:disable Rails/SaveBang
expect(DraftNote.count).to eq(0)
end
@@ -45,7 +45,7 @@ RSpec.describe DraftNotes::DestroyService do
allow_any_instance_of(DraftNote).to receive_message_chain(:diff_file, :unfolded?) { true }
expect(merge_request).to receive_message_chain(:diffs, :clear_cache)
- destroy
+ destroy # rubocop:disable Rails/SaveBang
end
end
end
diff --git a/spec/services/emails/confirm_service_spec.rb b/spec/services/emails/confirm_service_spec.rb
index 935a673f548..d3a745bc744 100644
--- a/spec/services/emails/confirm_service_spec.rb
+++ b/spec/services/emails/confirm_service_spec.rb
@@ -9,7 +9,7 @@ RSpec.describe Emails::ConfirmService do
describe '#execute' do
it 'enqueues a background job to send confirmation email again' do
- email = user.emails.create(email: 'new@email.com')
+ email = user.emails.create!(email: 'new@email.com')
expect { service.execute(email) }.to have_enqueued_job.on_queue('mailers')
end
diff --git a/spec/services/feature_flags/update_service_spec.rb b/spec/services/feature_flags/update_service_spec.rb
index a982dd5166b..66a75a2c24e 100644
--- a/spec/services/feature_flags/update_service_spec.rb
+++ b/spec/services/feature_flags/update_service_spec.rb
@@ -100,6 +100,13 @@ RSpec.describe FeatureFlags::UpdateService do
include('Updated active from <strong>"true"</strong> to <strong>"false"</strong>.')
)
end
+
+ it 'executes hooks' do
+ hook = create(:project_hook, :all_events_enabled, project: project)
+ expect(WebHookWorker).to receive(:perform_async).with(hook.id, an_instance_of(Hash), 'feature_flag_hooks')
+
+ subject
+ end
end
context 'when scope active state is changed' do
diff --git a/spec/services/groups/destroy_service_spec.rb b/spec/services/groups/destroy_service_spec.rb
index 31afdba8192..e06f09d0463 100644
--- a/spec/services/groups/destroy_service_spec.rb
+++ b/spec/services/groups/destroy_service_spec.rb
@@ -95,7 +95,7 @@ RSpec.describe Groups::DestroyService do
context 'projects in pending_delete' do
before do
project.pending_delete = true
- project.save
+ project.save!
end
it_behaves_like 'group destruction', false
diff --git a/spec/services/groups/import_export/import_service_spec.rb b/spec/services/groups/import_export/import_service_spec.rb
index f284225e23a..f8cb55a9955 100644
--- a/spec/services/groups/import_export/import_service_spec.rb
+++ b/spec/services/groups/import_export/import_service_spec.rb
@@ -63,7 +63,7 @@ RSpec.describe Groups::ImportExport::ImportService do
before do
stub_feature_flags(group_import_ndjson: false)
- ImportExportUpload.create(group: group, import_file: import_file)
+ 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)
@@ -105,7 +105,7 @@ RSpec.describe Groups::ImportExport::ImportService do
subject { service.execute }
before do
- ImportExportUpload.create(group: group, import_file: import_file)
+ 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)
@@ -216,7 +216,7 @@ RSpec.describe Groups::ImportExport::ImportService do
subject { service.execute }
before do
- ImportExportUpload.create(group: group, import_file: import_file)
+ 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)
diff --git a/spec/services/issuable/common_system_notes_service_spec.rb b/spec/services/issuable/common_system_notes_service_spec.rb
index fc01ee8f672..a988ab81754 100644
--- a/spec/services/issuable/common_system_notes_service_spec.rb
+++ b/spec/services/issuable/common_system_notes_service_spec.rb
@@ -36,28 +36,28 @@ RSpec.describe Issuable::CommonSystemNotesService do
context 'adding Draft note' do
let(:issuable) { create(:merge_request, title: "merge request") }
- it_behaves_like 'system note creation', { title: "Draft: merge request" }, 'marked as a **Work In Progress**'
+ it_behaves_like 'system note creation', { title: "Draft: merge request" }, 'marked this merge request as **draft**'
context 'and changing title' do
before do
issuable.update_attribute(:title, "Draft: changed title")
end
- it_behaves_like 'draft notes creation', 'marked'
+ it_behaves_like 'draft notes creation', 'draft'
end
end
context 'removing Draft note' do
let(:issuable) { create(:merge_request, title: "Draft: merge request") }
- it_behaves_like 'system note creation', { title: "merge request" }, 'unmarked as a **Work In Progress**'
+ it_behaves_like 'system note creation', { title: "merge request" }, 'marked this merge request as **ready**'
context 'and changing title' do
before do
issuable.update_attribute(:title, "changed title")
end
- it_behaves_like 'draft notes creation', 'unmarked'
+ it_behaves_like 'draft notes creation', 'ready'
end
end
end
diff --git a/spec/services/issues/import_csv_service_spec.rb b/spec/services/issues/import_csv_service_spec.rb
index cc3e1d23a74..fa40b75190f 100644
--- a/spec/services/issues/import_csv_service_spec.rb
+++ b/spec/services/issues/import_csv_service_spec.rb
@@ -5,108 +5,15 @@ require 'spec_helper'
RSpec.describe Issues::ImportCsvService do
let(:project) { create(:project) }
let(:user) { create(:user) }
-
- subject do
+ let(:service) do
uploader = FileUploader.new(project)
uploader.store!(file)
- described_class.new(user, project, uploader).execute
+ described_class.new(user, project, uploader)
end
- describe '#execute' do
- context 'invalid file' do
- let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
-
- it 'returns invalid file error' do
- expect(Notify).to receive_message_chain(:import_issues_csv_email, :deliver_later)
-
- expect(subject[:success]).to eq(0)
- expect(subject[:parse_error]).to eq(true)
- end
- end
-
- context 'with a file generated by Gitlab CSV export' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_gitlab_export.csv') }
-
- it 'imports the CSV without errors' do
- expect(Notify).to receive_message_chain(:import_issues_csv_email, :deliver_later)
-
- expect(subject[:success]).to eq(4)
- expect(subject[:error_lines]).to eq([])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issue attributes' do
- expect { subject }.to change { project.issues.count }.by 4
-
- expect(project.issues.reload.last).to have_attributes(
- title: 'Test Title',
- description: 'Test Description'
- )
- end
- end
-
- context 'comma delimited file' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
-
- it 'imports CSV without errors' do
- expect(Notify).to receive_message_chain(:import_issues_csv_email, :deliver_later)
-
- expect(subject[:success]).to eq(3)
- expect(subject[:error_lines]).to eq([])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issue attributes' do
- expect { subject }.to change { project.issues.count }.by 3
-
- expect(project.issues.reload.last).to have_attributes(
- title: 'Title with quote"',
- description: 'Description'
- )
- end
- end
-
- context 'tab delimited file with error row' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
-
- it 'imports CSV with some error rows' do
- expect(Notify).to receive_message_chain(:import_issues_csv_email, :deliver_later)
-
- expect(subject[:success]).to eq(2)
- expect(subject[:error_lines]).to eq([3])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issue attributes' do
- expect { subject }.to change { project.issues.count }.by 2
-
- expect(project.issues.reload.last).to have_attributes(
- title: 'Hello',
- description: 'World'
- )
- end
- end
-
- context 'semicolon delimited file with CRLF' do
- let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
-
- it 'imports CSV with a blank row' do
- expect(Notify).to receive_message_chain(:import_issues_csv_email, :deliver_later)
-
- expect(subject[:success]).to eq(3)
- expect(subject[:error_lines]).to eq([4])
- expect(subject[:parse_error]).to eq(false)
- end
-
- it 'correctly sets the issue attributes' do
- expect { subject }.to change { project.issues.count }.by 3
-
- expect(project.issues.reload.last).to have_attributes(
- title: 'Hello',
- description: 'World'
- )
- end
- end
+ include_examples 'issuable import csv service', 'issue' do
+ let(:issuables) { project.issues }
+ let(:email_method) { :import_issues_csv_email }
end
end
diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb
index ae1454ce9bb..9b8d21bb8eb 100644
--- a/spec/services/issues/move_service_spec.rb
+++ b/spec/services/issues/move_service_spec.rb
@@ -321,21 +321,40 @@ RSpec.describe Issues::MoveService do
before do
authorized_project.add_developer(user)
+ authorized_project.add_developer(admin)
authorized_project2.add_developer(user)
+ authorized_project2.add_developer(admin)
end
context 'multiple related issues' do
- it 'moves all related issues and retains permissions' do
- new_issue = move_service.execute(old_issue, new_project)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'moves all related issues and retains permissions' do
+ new_issue = move_service.execute(old_issue, new_project)
+
+ expect(new_issue.related_issues(admin))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d, unauthorized_issue])
+
+ expect(new_issue.related_issues(user))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d])
+
+ expect(authorized_issue_d.related_issues(user))
+ .to match_array([new_issue])
+ end
+ end
- expect(new_issue.related_issues(admin))
- .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d, unauthorized_issue])
+ context 'when admin mode is disabled' do
+ it 'moves all related issues and retains permissions' do
+ new_issue = move_service.execute(old_issue, new_project)
- expect(new_issue.related_issues(user))
- .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d])
+ expect(new_issue.related_issues(admin))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d])
- expect(authorized_issue_d.related_issues(user))
- .to match_array([new_issue])
+ expect(new_issue.related_issues(user))
+ .to match_array([authorized_issue_b, authorized_issue_c, authorized_issue_d])
+
+ expect(authorized_issue_d.related_issues(user))
+ .to match_array([new_issue])
+ end
end
end
end
diff --git a/spec/services/issues/related_branches_service_spec.rb b/spec/services/issues/related_branches_service_spec.rb
index 1780023803a..a8a1f95e800 100644
--- a/spec/services/issues/related_branches_service_spec.rb
+++ b/spec/services/issues/related_branches_service_spec.rb
@@ -74,8 +74,16 @@ RSpec.describe Issues::RelatedBranchesService do
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))
+ context 'when admin mode is enabled', :enable_admin_mode do
+ 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
+
+ context 'when admin mode is disabled' do
+ it 'does not return info a developer could not see' do
+ expect(branch_info.pluck(:pipeline_status)).not_to include(an_instance_of(Gitlab::Ci::Status::Running))
+ end
end
end
diff --git a/spec/services/issues/zoom_link_service_spec.rb b/spec/services/issues/zoom_link_service_spec.rb
index b095cb24212..8e8adc516cf 100644
--- a/spec/services/issues/zoom_link_service_spec.rb
+++ b/spec/services/issues/zoom_link_service_spec.rb
@@ -46,10 +46,15 @@ RSpec.describe Issues::ZoomLinkService do
expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(zoom_link)
end
- it 'tracks the add event' do
- expect(Gitlab::Tracking).to receive(:event)
- .with('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id)
+ it 'tracks the add event', :snowplow do
result
+
+ expect_snowplow_event(
+ category: 'IncidentManagement::ZoomIntegration',
+ action: 'add_zoom_meeting',
+ label: 'Issue ID',
+ value: issue.id
+ )
end
it 'creates a zoom_link_added notification' do
@@ -180,10 +185,15 @@ RSpec.describe Issues::ZoomLinkService do
expect(ZoomMeeting.canonical_meeting_url(issue)).to eq(nil)
end
- it 'tracks the remove event' do
- expect(Gitlab::Tracking).to receive(:event)
- .with('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id)
+ it 'tracks the remove event', :snowplow do
result
+
+ expect_snowplow_event(
+ category: 'IncidentManagement::ZoomIntegration',
+ action: 'remove_zoom_meeting',
+ label: 'Issue ID',
+ value: issue.id
+ )
end
end
diff --git a/spec/services/jira_connect/sync_service_spec.rb b/spec/services/jira_connect/sync_service_spec.rb
index e26ca30d0e1..83088bb2e79 100644
--- a/spec/services/jira_connect/sync_service_spec.rb
+++ b/spec/services/jira_connect/sync_service_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe JiraConnect::SyncService do
project: project,
commits: commits,
branches: [instance_of(Gitlab::Git::Branch)],
- merge_requests: merge_requests
+ merge_requests: merge_requests,
+ update_sequence_id: anything
).and_return(return_value)
end
end
diff --git a/spec/services/jira_connect_subscriptions/create_service_spec.rb b/spec/services/jira_connect_subscriptions/create_service_spec.rb
index 77e758cf6fe..9750c671fa2 100644
--- a/spec/services/jira_connect_subscriptions/create_service_spec.rb
+++ b/spec/services/jira_connect_subscriptions/create_service_spec.rb
@@ -32,6 +32,36 @@ RSpec.describe JiraConnectSubscriptions::CreateService do
it 'returns success' do
expect(subject[:status]).to eq(:success)
end
+
+ context 'namespace has projects' do
+ let!(:project_1) { create(:project, group: group) }
+ let!(:project_2) { create(:project, group: group) }
+
+ before do
+ stub_const("#{described_class}::MERGE_REQUEST_SYNC_BATCH_SIZE", 1)
+ end
+
+ it 'starts workers to sync projects in batches with delay' do
+ allow(Atlassian::JiraConnect::Client).to receive(:generate_update_sequence_id).and_return(123)
+
+ expect(JiraConnect::SyncProjectWorker).to receive(:bulk_perform_in).with(1.minute, [[project_1.id, 123]])
+ expect(JiraConnect::SyncProjectWorker).to receive(:bulk_perform_in).with(2.minutes, [[project_2.id, 123]])
+
+ subject
+ end
+
+ context 'when the jira_connect_full_namespace_sync feature flag is disabled' do
+ before do
+ stub_feature_flags(jira_connect_full_namespace_sync: false)
+ end
+
+ specify do
+ expect(JiraConnect::SyncProjectWorker).not_to receive(:bulk_perform_in_with_contexts)
+
+ subject
+ end
+ end
+ end
end
context 'when path is invalid' do
diff --git a/spec/services/jira_import/cloud_users_mapper_service_spec.rb b/spec/services/jira_import/cloud_users_mapper_service_spec.rb
index 591f80f3efc..6b06a982a80 100644
--- a/spec/services/jira_import/cloud_users_mapper_service_spec.rb
+++ b/spec/services/jira_import/cloud_users_mapper_service_spec.rb
@@ -5,15 +5,44 @@ require 'spec_helper'
RSpec.describe JiraImport::CloudUsersMapperService do
let(:start_at) { 7 }
let(:url) { "/rest/api/2/users?maxResults=50&startAt=#{start_at}" }
+
+ let_it_be(:user_1) { create(:user, username: 'randomuser', name: 'USER-name1', email: 'uji@example.com') }
+ let_it_be(:user_2) { create(:user, username: 'username-2') }
+ let_it_be(:user_5) { create(:user, username: 'username-5') }
+ let_it_be(:user_4) { create(:user, email: 'user-4@example.com') }
+ let_it_be(:user_6) { create(:user, email: 'user-6@example.com') }
+ let_it_be(:user_7) { create(:user, username: 'username-7') }
+ let_it_be(:user_8) do
+ create(:user).tap { |user| create(:email, user: user, email: 'user8_email@example.com') }
+ end
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
let(:jira_users) do
[
- { 'accountId' => 'abcd', 'displayName' => 'user1' },
- { 'accountId' => 'efg' },
- { 'accountId' => 'hij', 'displayName' => 'user3', 'emailAddress' => 'user3@example.com' }
+ { 'accountId' => 'abcd', 'displayName' => 'User-Name1' }, # matched by name
+ { 'accountId' => 'efg', 'displayName' => 'username-2' }, # matcher by username
+ { 'accountId' => 'hij' }, # no match
+ { 'accountId' => '123', 'displayName' => 'user-4', 'emailAddress' => 'user-4@example.com' }, # matched by email
+ { 'accountId' => '456', 'displayName' => 'username5foo', 'emailAddress' => 'user-5@example.com' }, # no match
+ { 'accountId' => '789', 'displayName' => 'user-6', 'emailAddress' => 'user-6@example.com' }, # matched by email, no project member
+ { 'accountId' => 'xyz', 'displayName' => 'username-7', 'emailAddress' => 'user-7@example.com' }, # matched by username, no project member
+ { 'accountId' => 'vhk', 'displayName' => 'user-8', 'emailAddress' => 'user8_email@example.com' }, # matched by secondary email
+ { 'accountId' => 'uji', 'displayName' => 'user-9', 'emailAddress' => 'uji@example.com' } # matched by email, same as user_1
]
end
describe '#execute' do
+ before do
+ project.add_developer(current_user)
+ project.add_developer(user_1)
+ project.add_developer(user_2)
+ group.add_developer(user_4)
+ group.add_guest(user_8)
+ end
+
it_behaves_like 'mapping jira users'
end
end
diff --git a/spec/services/jira_import/server_users_mapper_service_spec.rb b/spec/services/jira_import/server_users_mapper_service_spec.rb
index 22cb0327cc5..71cb8aea0be 100644
--- a/spec/services/jira_import/server_users_mapper_service_spec.rb
+++ b/spec/services/jira_import/server_users_mapper_service_spec.rb
@@ -5,15 +5,44 @@ require 'spec_helper'
RSpec.describe JiraImport::ServerUsersMapperService do
let(:start_at) { 7 }
let(:url) { "/rest/api/2/user/search?username=''&maxResults=50&startAt=#{start_at}" }
+
+ let_it_be(:user_1) { create(:user, username: 'randomuser', name: 'USER-name1', email: 'uji@example.com') }
+ let_it_be(:user_2) { create(:user, username: 'username-2') }
+ let_it_be(:user_5) { create(:user, username: 'username-5') }
+ let_it_be(:user_4) { create(:user, email: 'user-4@example.com') }
+ let_it_be(:user_6) { create(:user, email: 'user-6@example.com') }
+ let_it_be(:user_7) { create(:user, username: 'username-7') }
+ let_it_be(:user_8) do
+ create(:user).tap { |user| create(:email, user: user, email: 'user8_email@example.com') }
+ end
+
+ let_it_be(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, group: group) }
+
let(:jira_users) do
[
- { 'key' => 'abcd', 'name' => 'user1' },
- { 'key' => 'efg' },
- { 'key' => 'hij', 'name' => 'user3', 'emailAddress' => 'user3@example.com' }
+ { 'key' => 'abcd', 'name' => 'User-Name1' }, # matched by name
+ { 'key' => 'efg', 'name' => 'username-2' }, # matcher by username
+ { 'key' => 'hij' }, # no match
+ { 'key' => '123', 'name' => 'user-4', 'emailAddress' => 'user-4@example.com' }, # matched by email
+ { 'key' => '456', 'name' => 'username5foo', 'emailAddress' => 'user-5@example.com' }, # no match
+ { 'key' => '789', 'name' => 'user-6', 'emailAddress' => 'user-6@example.com' }, # matched by email, no project member
+ { 'key' => 'xyz', 'name' => 'username-7', 'emailAddress' => 'user-7@example.com' }, # matched by username, no project member
+ { 'key' => 'vhk', 'name' => 'user-8', 'emailAddress' => 'user8_email@example.com' }, # matched by secondary email
+ { 'key' => 'uji', 'name' => 'user-9', 'emailAddress' => 'uji@example.com' } # matched by email, same as user_1
]
end
describe '#execute' do
+ before do
+ project.add_developer(current_user)
+ project.add_developer(user_1)
+ project.add_developer(user_2)
+ group.add_developer(user_4)
+ group.add_guest(user_8)
+ end
+
it_behaves_like 'mapping jira users'
end
end
diff --git a/spec/services/jira_import/users_importer_spec.rb b/spec/services/jira_import/users_importer_spec.rb
index efb303dab9f..7112443502c 100644
--- a/spec/services/jira_import/users_importer_spec.rb
+++ b/spec/services/jira_import/users_importer_spec.rb
@@ -6,7 +6,8 @@ RSpec.describe JiraImport::UsersImporter do
include JiraServiceHelper
let_it_be(:user) { create(:user) }
- let_it_be(:project, reload: true) { create(:project) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project, reload: true) { create(:project, group: group) }
let_it_be(:start_at) { 7 }
let(:importer) { described_class.new(user, project, start_at) }
@@ -18,19 +19,15 @@ RSpec.describe JiraImport::UsersImporter do
[
{
jira_account_id: 'acc1',
- jira_display_name: 'user1',
+ jira_display_name: 'user-name1',
jira_email: 'sample@jira.com',
- gitlab_id: nil,
- gitlab_username: nil,
- gitlab_name: nil
+ gitlab_id: project_member.id
},
{
jira_account_id: 'acc2',
- jira_display_name: 'user2',
+ jira_display_name: 'user-name2',
jira_email: nil,
- gitlab_id: nil,
- gitlab_username: nil,
- gitlab_name: nil
+ gitlab_id: group_member.id
}
]
end
@@ -69,13 +66,22 @@ RSpec.describe JiraImport::UsersImporter do
context 'when jira client returns an empty array' do
let(:jira_users) { [] }
- it 'retturns nil payload' do
+ it 'returns nil payload' do
expect(subject.success?).to be_truthy
expect(subject.payload).to be_empty
end
end
context 'when jira client returns an results' do
+ let_it_be(:project_member) { create(:user, email: 'sample@jira.com') }
+ let_it_be(:group_member) { create(:user, name: 'user-name2') }
+ let_it_be(:other_user) { create(:user) }
+
+ before do
+ project.add_developer(project_member)
+ group.add_developer(group_member)
+ end
+
it 'returns the mapped users' do
expect(subject.success?).to be_truthy
expect(subject.payload).to eq(mapped_users)
@@ -90,8 +96,8 @@ RSpec.describe JiraImport::UsersImporter do
let(:url) { "/rest/api/2/user/search?username=''&maxResults=50&startAt=#{start_at}" }
let(:jira_users) do
[
- { 'key' => 'acc1', 'name' => 'user1', 'emailAddress' => 'sample@jira.com' },
- { 'key' => 'acc2', 'name' => 'user2' }
+ { 'key' => 'acc1', 'name' => 'user-name1', 'emailAddress' => 'sample@jira.com' },
+ { 'key' => 'acc2', 'name' => 'user-name2' }
]
end
@@ -110,8 +116,8 @@ RSpec.describe JiraImport::UsersImporter do
let(:url) { "/rest/api/2/users?maxResults=50&startAt=#{start_at}" }
let(:jira_users) do
[
- { 'accountId' => 'acc1', 'displayName' => 'user1', 'emailAddress' => 'sample@jira.com' },
- { 'accountId' => 'acc2', 'displayName' => 'user2' }
+ { 'accountId' => 'acc1', 'displayName' => 'user-name1', 'emailAddress' => 'sample@jira.com' },
+ { 'accountId' => 'acc2', 'displayName' => 'user-name2' }
]
end
diff --git a/spec/services/labels/promote_service_spec.rb b/spec/services/labels/promote_service_spec.rb
index 7674ec36331..15d53857f33 100644
--- a/spec/services/labels/promote_service_spec.rb
+++ b/spec/services/labels/promote_service_spec.rb
@@ -63,139 +63,149 @@ RSpec.describe Labels::PromoteService do
expect(service.execute(group_label)).to be_falsey
end
- it 'is truthy on success' do
- expect(service.execute(project_label_1_1)).to be_truthy
- end
+ shared_examples 'promoting a project label to a group label' do
+ it 'is truthy on success' do
+ expect(service.execute(project_label_1_1)).to be_truthy
+ end
- it 'recreates the label as a group label' do
- expect { service.execute(project_label_1_1) }
- .to change(project_1.labels, :count).by(-1)
- .and change(group_1.labels, :count).by(1)
- expect(new_label).not_to be_nil
- end
+ it 'removes all project labels with that title within the group' do
+ expect { service.execute(project_label_1_1) }.to change(project_2.labels, :count).by(-1).and \
+ change(project_3.labels, :count).by(-1)
+ end
- it 'copies title, description and color' do
- service.execute(project_label_1_1)
+ it 'keeps users\' subscriptions' do
+ user2 = create(:user)
+ project_label_1_1.subscriptions.create!(user: user, subscribed: true)
+ project_label_2_1.subscriptions.create!(user: user, subscribed: true)
+ project_label_3_2.subscriptions.create!(user: user, subscribed: true)
+ project_label_2_1.subscriptions.create!(user: user2, subscribed: true)
- expect(new_label.title).to eq(promoted_label_name)
- expect(new_label.description).to eq(promoted_description)
- expect(new_label.color).to eq(promoted_color)
- end
+ expect { service.execute(project_label_1_1) }.to change { Subscription.count }.from(4).to(3)
- it 'merges labels with the same name in group' do
- expect { service.execute(project_label_1_1) }.to change(project_2.labels, :count).by(-1).and \
- change(project_3.labels, :count).by(-1)
- end
-
- it 'keeps users\' subscriptions' do
- user2 = create(:user)
- project_label_1_1.subscriptions.create(user: user, subscribed: true)
- project_label_2_1.subscriptions.create(user: user, subscribed: true)
- project_label_3_2.subscriptions.create(user: user, subscribed: true)
- project_label_2_1.subscriptions.create(user: user2, subscribed: true)
+ expect(new_label.subscribed?(user)).to be_truthy
+ expect(new_label.subscribed?(user2)).to be_truthy
+ end
- expect { service.execute(project_label_1_1) }.to change { Subscription.count }.from(4).to(3)
+ it 'recreates priorities' do
+ service.execute(project_label_1_1)
- expect(new_label.subscribed?(user)).to be_truthy
- expect(new_label.subscribed?(user2)).to be_truthy
- end
+ expect(new_label.priority(project_1)).to be_nil
+ expect(new_label.priority(project_2)).to eq(label_2_1_priority)
+ expect(new_label.priority(project_3)).to eq(label_3_1_priority)
+ end
- it 'recreates priorities' do
- service.execute(project_label_1_1)
+ it 'does not touch project out of promoted group' do
+ service.execute(project_label_1_1)
+ project_4_new_label = project_4.labels.find_by(title: promoted_label_name)
- expect(new_label.priority(project_1)).to be_nil
- expect(new_label.priority(project_2)).to eq(label_2_1_priority)
- expect(new_label.priority(project_3)).to eq(label_3_1_priority)
- end
+ expect(project_4_new_label).not_to be_nil
+ expect(project_4_new_label.id).to eq(project_label_4_1.id)
+ end
- it 'does not touch project out of promoted group' do
- service.execute(project_label_1_1)
- project_4_new_label = project_4.labels.find_by(title: promoted_label_name)
+ it 'does not touch out of group priority' do
+ service.execute(project_label_1_1)
- expect(project_4_new_label).not_to be_nil
- expect(project_4_new_label.id).to eq(project_label_4_1.id)
- end
+ expect(new_label.priority(project_4)).to be_nil
+ end
- it 'does not touch out of group priority' do
- service.execute(project_label_1_1)
+ it 'relinks issue with the promoted label' do
+ service.execute(project_label_1_1)
+ issue_label = issue_1_1.labels.find_by(title: promoted_label_name)
- expect(new_label.priority(project_4)).to be_nil
- end
+ expect(issue_label).not_to be_nil
+ expect(issue_label.id).to eq(new_label.id)
+ end
- it 'relinks issue with the promoted label' do
- service.execute(project_label_1_1)
- issue_label = issue_1_1.labels.find_by(title: promoted_label_name)
+ it 'does not remove untouched labels from issue' do
+ expect { service.execute(project_label_1_1) }.not_to change(issue_1_1.labels, :count)
+ end
- expect(issue_label).not_to be_nil
- expect(issue_label.id).to eq(new_label.id)
- end
+ it 'does not relink untouched label in issue' do
+ service.execute(project_label_1_1)
+ issue_label = issue_1_2.labels.find_by(title: untouched_label_name)
- it 'does not remove untouched labels from issue' do
- expect { service.execute(project_label_1_1) }.not_to change(issue_1_1.labels, :count)
- end
+ expect(issue_label).not_to be_nil
+ expect(issue_label.id).to eq(project_label_1_2.id)
+ end
- it 'does not relink untouched label in issue' do
- service.execute(project_label_1_1)
- issue_label = issue_1_2.labels.find_by(title: untouched_label_name)
+ it 'relinks issues with merged labels' do
+ service.execute(project_label_1_1)
+ issue_label = issue_2_1.labels.find_by(title: promoted_label_name)
- expect(issue_label).not_to be_nil
- expect(issue_label.id).to eq(project_label_1_2.id)
- end
+ expect(issue_label).not_to be_nil
+ expect(issue_label.id).to eq(new_label.id)
+ end
- it 'relinks issues with merged labels' do
- service.execute(project_label_1_1)
- issue_label = issue_2_1.labels.find_by(title: promoted_label_name)
+ it 'does not relink issues from other group' do
+ service.execute(project_label_1_1)
+ issue_label = issue_4_1.labels.find_by(title: promoted_label_name)
- expect(issue_label).not_to be_nil
- expect(issue_label.id).to eq(new_label.id)
- end
+ expect(issue_label).not_to be_nil
+ expect(issue_label.id).to eq(project_label_4_1.id)
+ end
- it 'does not relink issues from other group' do
- service.execute(project_label_1_1)
- issue_label = issue_4_1.labels.find_by(title: promoted_label_name)
+ it 'updates merge request' do
+ service.execute(project_label_1_1)
+ merge_label = merge_3_1.labels.find_by(title: promoted_label_name)
- expect(issue_label).not_to be_nil
- expect(issue_label.id).to eq(project_label_4_1.id)
- end
+ expect(merge_label).not_to be_nil
+ expect(merge_label.id).to eq(new_label.id)
+ end
- it 'updates merge request' do
- service.execute(project_label_1_1)
- merge_label = merge_3_1.labels.find_by(title: promoted_label_name)
+ it 'updates board lists' do
+ service.execute(project_label_1_1)
+ list = issue_board_2_1.lists.find_by(label: new_label)
- expect(merge_label).not_to be_nil
- expect(merge_label.id).to eq(new_label.id)
- end
+ expect(list).not_to be_nil
+ end
- it 'updates board lists' do
- service.execute(project_label_1_1)
- list = issue_board_2_1.lists.find_by(label: new_label)
+ # In case someone adds a new relation to Label.rb and forgets to relink it
+ # and the database doesn't have foreign key constraints
+ it 'relinks all relations' do
+ service.execute(project_label_1_1)
- expect(list).not_to be_nil
+ Label.reflect_on_all_associations.each do |association|
+ expect(project_label_1_1.send(association.name).any?).to be_falsey
+ end
+ end
end
- # In case someone adds a new relation to Label.rb and forgets to relink it
- # and the database doesn't have foreign key constraints
- it 'relinks all relations' do
- service.execute(project_label_1_1)
+ context 'if there is an existing identical group label' do
+ let!(:existing_group_label) { create(:group_label, group: group_1, title: project_label_1_1.title ) }
+
+ it 'uses the existing group label' do
+ expect { service.execute(project_label_1_1) }
+ .to change(project_1.labels, :count).by(-1)
+ .and not_change(group_1.labels, :count)
+ expect(new_label).not_to be_nil
+ end
- Label.reflect_on_all_associations.each do |association|
- expect(project_label_1_1.send(association.name).any?).to be_falsey
+ it 'does not create a new group label clone' do
+ expect { service.execute(project_label_1_1) }.not_to change { GroupLabel.maximum(:id) }
end
+
+ it_behaves_like 'promoting a project label to a group label'
end
- context 'with invalid group label' do
- before do
- allow(service).to receive(:clone_label_to_group_label).and_wrap_original do |m, *args|
- label = m.call(*args)
- allow(label).to receive(:valid?).and_return(false)
+ context 'if there is no existing identical group label' do
+ let(:existing_group_label) { nil }
- label
- end
+ it 'recreates the label as a group label' do
+ expect { service.execute(project_label_1_1) }
+ .to change(project_1.labels, :count).by(-1)
+ .and change(group_1.labels, :count).by(1)
+ expect(new_label).not_to be_nil
end
- it 'raises an exception' do
- expect { service.execute(project_label_1_1) }.to raise_error(ActiveRecord::RecordInvalid)
+ it 'copies title, description and color to cloned group label' do
+ service.execute(project_label_1_1)
+
+ expect(new_label.title).to eq(promoted_label_name)
+ expect(new_label.description).to eq(promoted_description)
+ expect(new_label.color).to eq(promoted_color)
end
+
+ it_behaves_like 'promoting a project label to a group label'
end
end
end
diff --git a/spec/services/labels/transfer_service_spec.rb b/spec/services/labels/transfer_service_spec.rb
index 2c0c82ed976..18fd401f383 100644
--- a/spec/services/labels/transfer_service_spec.rb
+++ b/spec/services/labels/transfer_service_spec.rb
@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Labels::TransferService do
describe '#execute' do
- let_it_be(:user) { create(:admin) }
+ let_it_be(:user) { create(:user) }
let_it_be(:old_group_ancestor) { create(:group) }
let_it_be(:old_group) { create(:group, parent: old_group_ancestor) }
@@ -15,6 +15,11 @@ RSpec.describe Labels::TransferService do
subject(:service) { described_class.new(user, old_group, project) }
+ before do
+ old_group_ancestor.add_developer(user)
+ new_group.add_developer(user)
+ end
+
it 'recreates missing group labels at project level and assigns them to the issuables' do
old_group_label_1 = create(:group_label, group: old_group)
old_group_label_2 = create(:group_label, group: old_group)
diff --git a/spec/services/members/invite_service_spec.rb b/spec/services/members/invite_service_spec.rb
new file mode 100644
index 00000000000..12a1a54696b
--- /dev/null
+++ b/spec/services/members/invite_service_spec.rb
@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Members::InviteService do
+ let(:project) { create(:project) }
+ let(:user) { create(:user) }
+ let(:project_user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'adds an existing user to members' do
+ params = { email: project_user.email.to_s, access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:success)
+ expect(project.users).to include project_user
+ end
+
+ it 'creates a new user for an unknown email address' do
+ params = { email: 'email@example.org', access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'limits the number of emails to 100' do
+ emails = Array.new(101).map { |n| "email#{n}@example.com" }
+ params = { email: emails, access_level: Gitlab::Access::GUEST }
+
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message]).to eq('Too many users specified (limit is 100)')
+ end
+
+ it 'does not invite an invalid email' do
+ params = { email: project_user.id.to_s, access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][project_user.id.to_s]).to eq("Invite email is invalid")
+ expect(project.users).not_to include project_user
+ end
+
+ it 'does not invite to an invalid access level' do
+ params = { email: project_user.email, access_level: -1 }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][project_user.email]).to eq("Access level is not included in the list")
+ end
+
+ it 'does not add a member with an existing invite' do
+ invited_member = create(:project_member, :invited, project: project)
+
+ params = { email: invited_member.invite_email,
+ access_level: Gitlab::Access::GUEST }
+ result = described_class.new(user, params).execute(project)
+
+ expect(result[:status]).to eq(:error)
+ expect(result[:message][invited_member.invite_email]).to eq("Member already invited to #{project.name}")
+ end
+end
diff --git a/spec/services/merge_requests/add_context_service_spec.rb b/spec/services/merge_requests/add_context_service_spec.rb
index 58ed91218d1..27b46a9023c 100644
--- a/spec/services/merge_requests/add_context_service_spec.rb
+++ b/spec/services/merge_requests/add_context_service_spec.rb
@@ -12,10 +12,20 @@ RSpec.describe MergeRequests::AddContextService do
subject(:service) { described_class.new(project, admin, merge_request: merge_request, commits: commits) }
describe "#execute" do
- it "adds context commit" do
- service.execute
+ context "when admin mode is enabled", :enable_admin_mode do
+ it "adds context commit" do
+ service.execute
- expect(merge_request.merge_request_context_commit_diff_files.length).to eq(2)
+ expect(merge_request.merge_request_context_commit_diff_files.length).to eq(2)
+ end
+ end
+
+ context "when admin mode is disabled" do
+ it "doesn't add context commit" do
+ subject.execute
+
+ expect(merge_request.merge_request_context_commit_diff_files.length).to eq(0)
+ end
end
context "when user doesn't have permission to update merge request" do
diff --git a/spec/services/merge_requests/cleanup_refs_service_spec.rb b/spec/services/merge_requests/cleanup_refs_service_spec.rb
index a051b3c9355..38c0e204e54 100644
--- a/spec/services/merge_requests/cleanup_refs_service_spec.rb
+++ b/spec/services/merge_requests/cleanup_refs_service_spec.rb
@@ -4,14 +4,15 @@ require 'spec_helper'
RSpec.describe MergeRequests::CleanupRefsService do
describe '.schedule' do
- let(:merge_request) { build(:merge_request) }
+ let(:merge_request) { create(:merge_request) }
- it 'schedules MergeRequestCleanupRefsWorker' do
- expect(MergeRequestCleanupRefsWorker)
- .to receive(:perform_in)
- .with(described_class::TIME_THRESHOLD, merge_request.id)
+ it 'creates a merge request cleanup schedule' do
+ freeze_time do
+ described_class.schedule(merge_request)
- described_class.schedule(merge_request)
+ expect(merge_request.reload.cleanup_schedule.scheduled_at)
+ .to eq(described_class::TIME_THRESHOLD.from_now)
+ end
end
end
@@ -20,6 +21,8 @@ RSpec.describe MergeRequests::CleanupRefsService do
# Need to re-enable this as it's being stubbed in spec_helper for
# performance reasons but is needed to run for this test.
allow(Gitlab::Git::KeepAround).to receive(:execute).and_call_original
+
+ merge_request.create_cleanup_schedule(scheduled_at: described_class::TIME_THRESHOLD.ago)
end
subject(:result) { described_class.new(merge_request).execute }
@@ -32,6 +35,7 @@ RSpec.describe MergeRequests::CleanupRefsService do
expect(result[:status]).to eq(:success)
expect(kept_around?(old_ref_head)).to be_truthy
expect(ref_head).to be_nil
+ expect(merge_request.cleanup_schedule.completed_at).to be_present
end
end
@@ -43,6 +47,7 @@ RSpec.describe MergeRequests::CleanupRefsService do
it 'does not fail' do
expect(result[:status]).to eq(:success)
+ expect(merge_request.cleanup_schedule.completed_at).to be_present
end
end
@@ -85,6 +90,44 @@ RSpec.describe MergeRequests::CleanupRefsService do
it_behaves_like 'service that does not clean up merge request refs'
end
+
+ context 'when cleanup schedule fails to update' do
+ before do
+ allow(merge_request.cleanup_schedule).to receive(:update).and_return(false)
+ end
+
+ it 'creates keep around ref and deletes merge request refs' do
+ old_ref_head = ref_head
+
+ aggregate_failures do
+ expect(result[:status]).to eq(:error)
+ expect(kept_around?(old_ref_head)).to be_truthy
+ expect(ref_head).to be_nil
+ expect(merge_request.cleanup_schedule.completed_at).not_to be_present
+ end
+ end
+ end
+
+ context 'when merge request is not scheduled to be cleaned up yet' do
+ before do
+ merge_request.cleanup_schedule.update!(scheduled_at: 1.day.from_now)
+ end
+
+ it_behaves_like 'service that does not clean up merge request refs'
+ end
+
+ context 'when repository no longer exists' do
+ before do
+ Repositories::DestroyService.new(merge_request.project.repository).execute
+ end
+
+ it 'does not fail and still mark schedule as complete' do
+ aggregate_failures do
+ expect(result[:status]).to eq(:success)
+ expect(merge_request.cleanup_schedule.completed_at).to be_present
+ end
+ end
+ end
end
shared_examples_for 'service that does not clean up merge request refs' do
@@ -92,6 +135,7 @@ RSpec.describe MergeRequests::CleanupRefsService do
aggregate_failures do
expect(result[:status]).to eq(:error)
expect(ref_head).to be_present
+ expect(merge_request.cleanup_schedule.completed_at).not_to be_present
end
end
end
diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb
index d603cbb16aa..3ccf02fcdfb 100644
--- a/spec/services/merge_requests/refresh_service_spec.rb
+++ b/spec/services/merge_requests/refresh_service_spec.rb
@@ -683,14 +683,14 @@ RSpec.describe MergeRequests::RefreshService do
end
end
- context 'marking the merge request as work in progress' do
+ context 'marking the merge request as draft' do
let(:refresh_service) { service.new(@project, @user) }
before do
allow(refresh_service).to receive(:execute_hooks)
end
- it 'marks the merge request as work in progress from fixup commits' do
+ it 'marks the merge request as draft from fixup commits' do
fixup_merge_request = create(:merge_request,
source_project: @project,
source_branch: 'wip',
@@ -705,11 +705,11 @@ RSpec.describe MergeRequests::RefreshService do
expect(fixup_merge_request.work_in_progress?).to eq(true)
expect(fixup_merge_request.notes.last.note).to match(
- /marked as a \*\*Work In Progress\*\* from #{Commit.reference_pattern}/
+ /marked this merge request as \*\*draft\*\* from #{Commit.reference_pattern}/
)
end
- it 'references the commit that caused the Work in Progress status' do
+ it 'references the commit that caused the draft status' do
wip_merge_request = create(:merge_request,
source_project: @project,
source_branch: 'wip',
@@ -724,11 +724,11 @@ RSpec.describe MergeRequests::RefreshService do
refresh_service.execute(oldrev, newrev, 'refs/heads/wip')
expect(wip_merge_request.reload.notes.last.note).to eq(
- "marked as a **Work In Progress** from #{wip_commit.id}"
+ "marked this merge request as **draft** from #{wip_commit.id}"
)
end
- it 'does not mark as WIP based on commits that do not belong to an MR' do
+ it 'does not mark as draft based on commits that do not belong to an MR' do
allow(refresh_service).to receive(:find_new_commits)
refresh_service.instance_variable_set("@commits", [
double(
diff --git a/spec/services/merge_requests/reopen_service_spec.rb b/spec/services/merge_requests/reopen_service_spec.rb
index ffc2ebb344c..2bd83dc36a8 100644
--- a/spec/services/merge_requests/reopen_service_spec.rb
+++ b/spec/services/merge_requests/reopen_service_spec.rb
@@ -23,6 +23,8 @@ RSpec.describe MergeRequests::ReopenService do
before do
allow(service).to receive(:execute_hooks)
+ merge_request.create_cleanup_schedule(scheduled_at: Time.current)
+ merge_request.update_column(:merge_ref_sha, 'abc123')
perform_enqueued_jobs do
service.execute(merge_request)
@@ -43,6 +45,14 @@ RSpec.describe MergeRequests::ReopenService do
expect(email.subject).to include(merge_request.title)
end
+ it 'destroys cleanup schedule record' do
+ expect(merge_request.reload.cleanup_schedule).to be_nil
+ end
+
+ it 'clears the cached merge_ref_sha' do
+ expect(merge_request.reload.merge_ref_sha).to be_nil
+ end
+
context 'note creation' do
it 'creates resource state event about merge_request reopen' do
event = merge_request.resource_state_events.last
diff --git a/spec/services/metrics/dashboard/panel_preview_service_spec.rb b/spec/services/metrics/dashboard/panel_preview_service_spec.rb
index d58dee3e7a3..2877d22d1f3 100644
--- a/spec/services/metrics/dashboard/panel_preview_service_spec.rb
+++ b/spec/services/metrics/dashboard/panel_preview_service_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe Metrics::Dashboard::PanelPreviewService do
title: test panel
YML
end
+
let_it_be(:dashboard) do
{
panel_groups: [
diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb
index 1e5536a2d0b..3118956951e 100644
--- a/spec/services/notes/create_service_spec.rb
+++ b/spec/services/notes/create_service_spec.rb
@@ -67,147 +67,164 @@ RSpec.describe Notes::CreateService do
let(:current_user) { user }
end
end
- end
- context 'noteable highlight cache clearing' do
- let(:project_with_repo) { create(:project, :repository) }
- let(:merge_request) do
- create(:merge_request, source_project: project_with_repo,
- target_project: project_with_repo)
- end
+ it 'tracks issue comment usage data', :clean_gitlab_redis_shared_state do
+ event = Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_COMMENT_ADDED
+ counter = Gitlab::UsageDataCounters::HLLRedisCounter
- let(:position) do
- Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: 14,
- diff_refs: merge_request.diff_refs)
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_comment_added_action).with(author: user).and_call_original
+ expect do
+ described_class.new(project, user, opts).execute
+ end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
end
- let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h)
- end
+ context 'in a merge request' do
+ let_it_be(:project_with_repo) { create(:project, :repository) }
+ let_it_be(:merge_request) do
+ create(:merge_request, source_project: project_with_repo,
+ target_project: project_with_repo)
+ end
- before do
- allow_any_instance_of(Gitlab::Diff::Position)
- .to receive(:unfolded_diff?) { true }
- end
+ context 'issue comment usage data' do
+ let(:opts) do
+ { note: 'Awesome comment', noteable_type: 'MergeRequest', noteable_id: merge_request.id }
+ end
- it 'clears noteable diff cache when it was unfolded for the note position' do
- expect_any_instance_of(Gitlab::Diff::HighlightCache).to receive(:clear)
+ it 'does not track' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:track_issue_comment_added_action)
- described_class.new(project_with_repo, user, new_opts).execute
- end
+ described_class.new(project, user, opts).execute
+ end
+ end
- it 'does not clear cache when note is not the first of the discussion' do
- prev_note =
- create(:diff_note_on_merge_request, noteable: merge_request,
- project: project_with_repo)
- reply_opts =
- opts.merge(in_reply_to_discussion_id: prev_note.discussion_id,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h)
+ context 'noteable highlight cache clearing' do
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 14,
+ diff_refs: merge_request.diff_refs)
+ end
- expect(merge_request).not_to receive(:diffs)
+ let(:new_opts) do
+ opts.merge(in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h)
+ end
- described_class.new(project_with_repo, user, reply_opts).execute
- end
- end
+ before do
+ allow_any_instance_of(Gitlab::Diff::Position)
+ .to receive(:unfolded_diff?) { true }
+ end
- context 'note diff file' do
- let(:project_with_repo) { create(:project, :repository) }
- let(:merge_request) do
- create(:merge_request,
- source_project: project_with_repo,
- target_project: project_with_repo)
- end
+ it 'clears noteable diff cache when it was unfolded for the note position' do
+ expect_any_instance_of(Gitlab::Diff::HighlightCache).to receive(:clear)
- let(:line_number) { 14 }
- let(:position) do
- Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
- new_path: "files/ruby/popen.rb",
- old_line: nil,
- new_line: line_number,
- diff_refs: merge_request.diff_refs)
- end
+ described_class.new(project_with_repo, user, new_opts).execute
+ end
- let(:previous_note) do
- create(:diff_note_on_merge_request, noteable: merge_request, project: project_with_repo)
- end
+ it 'does not clear cache when note is not the first of the discussion' do
+ prev_note =
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ project: project_with_repo)
+ reply_opts =
+ opts.merge(in_reply_to_discussion_id: prev_note.discussion_id,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h)
- before do
- project_with_repo.add_maintainer(user)
- end
+ expect(merge_request).not_to receive(:diffs)
- context 'when eligible to have a note diff file' do
- let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h)
+ described_class.new(project_with_repo, user, reply_opts).execute
+ end
end
- it 'note is associated with a note diff file' do
- MergeRequests::MergeToRefService.new(merge_request.project, merge_request.author).execute(merge_request)
-
- note = described_class.new(project_with_repo, user, new_opts).execute
-
- expect(note).to be_persisted
- expect(note.note_diff_file).to be_present
- expect(note.diff_note_positions).to be_present
- end
- end
+ context 'note diff file' do
+ let(:line_number) { 14 }
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: line_number,
+ diff_refs: merge_request.diff_refs)
+ end
- context 'when DiffNote is a reply' do
- let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: previous_note.discussion_id,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: position.to_h)
- end
+ let(:previous_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request, project: project_with_repo)
+ end
- it 'note is not associated with a note diff file' do
- expect(Discussions::CaptureDiffNotePositionService).not_to receive(:new)
+ before do
+ project_with_repo.add_maintainer(user)
+ end
- note = described_class.new(project_with_repo, user, new_opts).execute
+ context 'when eligible to have a note diff file' do
+ let(:new_opts) do
+ opts.merge(in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h)
+ end
- expect(note).to be_persisted
- expect(note.note_diff_file).to be_nil
- end
+ it 'note is associated with a note diff file' do
+ MergeRequests::MergeToRefService.new(merge_request.project, merge_request.author).execute(merge_request)
- context 'when DiffNote from an image' do
- let(:image_position) do
- Gitlab::Diff::Position.new(old_path: "files/images/6049019_460s.jpg",
- new_path: "files/images/6049019_460s.jpg",
- width: 100,
- height: 100,
- x: 1,
- y: 100,
- diff_refs: merge_request.diff_refs,
- position_type: 'image')
- end
+ note = described_class.new(project_with_repo, user, new_opts).execute
- let(:new_opts) do
- opts.merge(in_reply_to_discussion_id: nil,
- type: 'DiffNote',
- noteable_type: 'MergeRequest',
- noteable_id: merge_request.id,
- position: image_position.to_h)
+ expect(note).to be_persisted
+ expect(note.note_diff_file).to be_present
+ expect(note.diff_note_positions).to be_present
+ end
end
- it 'note is not associated with a note diff file' do
- note = described_class.new(project_with_repo, user, new_opts).execute
-
- expect(note).to be_persisted
- expect(note.note_diff_file).to be_nil
+ context 'when DiffNote is a reply' do
+ let(:new_opts) do
+ opts.merge(in_reply_to_discussion_id: previous_note.discussion_id,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: position.to_h)
+ end
+
+ it 'note is not associated with a note diff file' do
+ expect(Discussions::CaptureDiffNotePositionService).not_to receive(:new)
+
+ note = described_class.new(project_with_repo, user, new_opts).execute
+
+ expect(note).to be_persisted
+ expect(note.note_diff_file).to be_nil
+ end
+
+ context 'when DiffNote from an image' do
+ let(:image_position) do
+ Gitlab::Diff::Position.new(old_path: "files/images/6049019_460s.jpg",
+ new_path: "files/images/6049019_460s.jpg",
+ width: 100,
+ height: 100,
+ x: 1,
+ y: 100,
+ diff_refs: merge_request.diff_refs,
+ position_type: 'image')
+ end
+
+ let(:new_opts) do
+ opts.merge(in_reply_to_discussion_id: nil,
+ type: 'DiffNote',
+ noteable_type: 'MergeRequest',
+ noteable_id: merge_request.id,
+ position: image_position.to_h)
+ end
+
+ it 'note is not associated with a note diff file' do
+ note = described_class.new(project_with_repo, user, new_opts).execute
+
+ expect(note).to be_persisted
+ expect(note.note_diff_file).to be_nil
+ end
+ end
end
end
end
@@ -286,7 +303,7 @@ RSpec.describe Notes::CreateService do
QuickAction.new(
action_text: "/wip",
before_action: -> {
- issuable.reload.update(title: "title")
+ issuable.reload.update!(title: "title")
},
expectation: ->(issuable, can_use_quick_action) {
expect(issuable.work_in_progress?).to eq(can_use_quick_action)
@@ -296,7 +313,7 @@ RSpec.describe Notes::CreateService do
QuickAction.new(
action_text: "/wip",
before_action: -> {
- issuable.reload.update(title: "WIP: title")
+ issuable.reload.update!(title: "WIP: title")
},
expectation: ->(noteable, can_use_quick_action) {
expect(noteable.work_in_progress?).not_to eq(can_use_quick_action)
diff --git a/spec/services/notes/destroy_service_spec.rb b/spec/services/notes/destroy_service_spec.rb
index d1076f77cec..0859c28cbe7 100644
--- a/spec/services/notes/destroy_service_spec.rb
+++ b/spec/services/notes/destroy_service_spec.rb
@@ -24,36 +24,55 @@ RSpec.describe Notes::DestroyService do
.to change { user.todos_pending_count }.from(1).to(0)
end
- context 'noteable highlight cache clearing' do
- let(:repo_project) { create(:project, :repository) }
- let(:merge_request) do
+ it 'tracks issue comment removal usage data', :clean_gitlab_redis_shared_state do
+ note = create(:note, project: project, noteable: issue)
+ event = Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_COMMENT_REMOVED
+ counter = Gitlab::UsageDataCounters::HLLRedisCounter
+
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_comment_removed_action).with(author: user).and_call_original
+ expect do
+ described_class.new(project, user).execute(note)
+ end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
+ end
+
+ context 'in a merge request' do
+ let_it_be(:repo_project) { create(:project, :repository) }
+ let_it_be(:merge_request) do
create(:merge_request, source_project: repo_project,
- target_project: repo_project)
+ target_project: repo_project)
end
- let(:note) do
+ let_it_be(:note) do
create(:diff_note_on_merge_request, project: repo_project,
- noteable: merge_request)
+ noteable: merge_request)
end
- before do
- allow(note.position).to receive(:unfolded_diff?) { true }
- end
-
- it 'clears noteable diff cache when it was unfolded for the note position' do
- expect(merge_request).to receive_message_chain(:diffs, :clear_cache)
+ it 'does not track issue comment removal usage data' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:track_issue_comment_removed_action)
described_class.new(repo_project, user).execute(note)
end
- it 'does not clear cache when note is not the first of the discussion' do
- reply_note = create(:diff_note_on_merge_request, in_reply_to: note,
- project: repo_project,
- noteable: merge_request)
+ context 'noteable highlight cache clearing' do
+ before do
+ allow(note.position).to receive(:unfolded_diff?) { true }
+ end
+
+ it 'clears noteable diff cache when it was unfolded for the note position' do
+ expect(merge_request).to receive_message_chain(:diffs, :clear_cache)
+
+ described_class.new(repo_project, user).execute(note)
+ end
+
+ it 'does not clear cache when note is not the first of the discussion' do
+ reply_note = create(:diff_note_on_merge_request, in_reply_to: note,
+ project: repo_project,
+ noteable: merge_request)
- expect(merge_request).not_to receive(:diffs)
+ expect(merge_request).not_to receive(:diffs)
- described_class.new(repo_project, user).execute(reply_note)
+ described_class.new(repo_project, user).execute(reply_note)
+ end
end
end
end
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 66efdf8abe7..e2f51c9af67 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -47,6 +47,22 @@ RSpec.describe Notes::UpdateService do
end
end
+ it 'does not track usage data when params is blank' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:track_issue_comment_edited_action)
+
+ update_note({})
+ end
+
+ it 'tracks usage data', :clean_gitlab_redis_shared_state do
+ event = Gitlab::UsageDataCounters::IssueActivityUniqueCounter::ISSUE_COMMENT_EDITED
+ counter = Gitlab::UsageDataCounters::HLLRedisCounter
+
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_comment_edited_action).with(author: user).and_call_original
+ expect do
+ update_note(note: 'new text')
+ end.to change { counter.unique_events(event_names: event, start_date: 1.day.ago, end_date: 1.day.from_now) }.by(1)
+ end
+
context 'with system note' do
before do
note.update_column(:system, true)
@@ -55,6 +71,12 @@ RSpec.describe Notes::UpdateService do
it 'does not update the note' do
expect { update_note(note: 'new text') }.not_to change { note.reload.note }
end
+
+ it 'does not track usage data' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:track_issue_comment_edited_action)
+
+ update_note(note: 'new text')
+ end
end
context 'suggestions' do
@@ -220,6 +242,12 @@ RSpec.describe Notes::UpdateService do
expect(note).not_to receive(:create_new_cross_references!)
update_note({ note: "Updated with new reference: #{issue.to_reference}" })
end
+
+ it 'does not track usage data' do
+ expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).not_to receive(:track_issue_comment_edited_action)
+
+ update_note(note: 'new text')
+ end
end
end
end
diff --git a/spec/services/notification_recipients/build_service_spec.rb b/spec/services/notification_recipients/build_service_spec.rb
index 5c8add250c2..cc08f9fceff 100644
--- a/spec/services/notification_recipients/build_service_spec.rb
+++ b/spec/services/notification_recipients/build_service_spec.rb
@@ -44,7 +44,7 @@ RSpec.describe NotificationRecipients::BuildService do
context 'when there are multiple subscribers' do
def create_user
subscriber = create(:user)
- issue.subscriptions.create(user: subscriber, project: project, subscribed: true)
+ issue.subscriptions.create!(user: subscriber, project: project, subscribed: true)
end
include_examples 'no N+1 queries'
@@ -96,7 +96,7 @@ RSpec.describe NotificationRecipients::BuildService do
context 'when there are multiple subscribers' do
def create_user
subscriber = create(:user)
- merge_request.subscriptions.create(user: subscriber, project: project, subscribed: true)
+ merge_request.subscriptions.create!(user: subscriber, project: project, subscribed: true)
end
include_examples 'no N+1 queries'
diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb
index caa9961424e..f9a89c6281e 100644
--- a/spec/services/notification_service_spec.rb
+++ b/spec/services/notification_service_spec.rb
@@ -361,7 +361,7 @@ RSpec.describe NotificationService, :mailer do
context 'where the project has disabled the feature' do
before do
- project.update(service_desk_enabled: false)
+ project.update!(service_desk_enabled: false)
end
it_should_not_email!
@@ -453,7 +453,7 @@ RSpec.describe NotificationService, :mailer do
context 'by note' do
before do
note.author = @u_lazy_participant
- note.save
+ note.save!
end
it { expect { subject }.not_to have_enqueued_email(@u_lazy_participant.id, note.id, mail: "note_issue_email") }
@@ -467,7 +467,7 @@ RSpec.describe NotificationService, :mailer do
note.project.namespace_id = group.id
group.add_user(@u_watcher, GroupMember::MAINTAINER)
group.add_user(@u_custom_global, GroupMember::MAINTAINER)
- note.project.save
+ note.project.save!
@u_watcher.notification_settings_for(note.project).participating!
@u_watcher.notification_settings_for(group).global!
@@ -834,53 +834,47 @@ RSpec.describe NotificationService, :mailer do
end
end
- context 'when notified of a new design diff note' do
+ context 'design diff note', :deliver_mails_inline 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(:member_and_mentioned) { create(:user, developer_projects: [project]) }
+ let_it_be(:member_and_author_of_second_note) { create(:user, developer_projects: [project]) }
+ let_it_be(:member_and_not_mentioned) { create(:user, developer_projects: [project]) }
+ let_it_be(:non_member_and_mentioned) { create(:user) }
let_it_be(:note) do
create(:diff_note_on_design,
noteable: design,
- note: "Hello #{dev.to_reference}, G'day #{stranger.to_reference}")
+ note: "Hello #{member_and_mentioned.to_reference}, G'day #{non_member_and_mentioned.to_reference}")
+ end
+
+ let_it_be(:note_2) do
+ create(:diff_note_on_design, noteable: design, author: member_and_author_of_second_note)
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)
+ it 'sends new note notifications', :aggregate_failures do
+ notification.new_note(note)
- subject.new_note(note)
+ should_email(design.authors.first)
+ should_email(member_and_mentioned)
+ should_email(member_and_author_of_second_note)
+ should_not_email(member_and_not_mentioned)
+ should_not_email(non_member_and_mentioned)
+ should_not_email(note.author)
end
end
context 'design management is disabled' do
- it 'does not notify the user' do
- expect(Notify).not_to receive(:note_design_email)
+ it 'does not notify anyone' do
+ notification.new_note(note)
- subject.new_note(note)
+ should_not_email_anyone
end
end
end
@@ -1719,7 +1713,7 @@ RSpec.describe NotificationService, :mailer do
before do
merge_request.author = participant
- merge_request.save
+ merge_request.save!
notification.new_merge_request(merge_request, @u_disabled)
end
@@ -1962,7 +1956,7 @@ RSpec.describe NotificationService, :mailer do
describe 'when merge_when_pipeline_succeeds is true' do
before do
- merge_request.update(
+ merge_request.update!(
merge_when_pipeline_succeeds: true,
merge_user: create(:user)
)
@@ -2312,6 +2306,26 @@ RSpec.describe NotificationService, :mailer do
end
end
+ describe '#new_instance_access_request', :deliver_mails_inline do
+ let_it_be(:user) { create(:user, :blocked_pending_approval) }
+ let_it_be(:admins) { create_list(:admin, 12, :with_sign_ins) }
+
+ subject { notification.new_instance_access_request(user) }
+
+ before do
+ reset_delivered_emails!
+ stub_application_setting(require_admin_approval_after_user_signup: true)
+ end
+
+ it 'sends notification only to a maximum of ten most recently active instance admins' do
+ ten_most_recently_active_instance_admins = User.admins.active.sort_by(&:current_sign_in_at).last(10)
+
+ subject
+
+ should_only_email(*ten_most_recently_active_instance_admins)
+ end
+ end
+
describe 'GroupMember', :deliver_mails_inline do
let(:added_user) { create(:user) }
@@ -2725,7 +2739,7 @@ RSpec.describe NotificationService, :mailer do
before do
group = create(:group)
- project.update(group: group)
+ project.update!(group: group)
create(:email, :confirmed, user: u_custom_notification_enabled, email: group_notification_email)
create(:notification_setting, user: u_custom_notification_enabled, source: group, notification_email: group_notification_email)
@@ -2761,7 +2775,7 @@ RSpec.describe NotificationService, :mailer do
before do
group = create(:group)
- project.update(group: group)
+ project.update!(group: group)
create(:email, :confirmed, user: u_member, email: group_notification_email)
create(:notification_setting, user: u_member, source: group, notification_email: group_notification_email)
end
@@ -2821,7 +2835,7 @@ RSpec.describe NotificationService, :mailer do
context 'when the creator has no read_build access' do
before do
pipeline = create_pipeline(u_member, :failed)
- project.update(public_builds: false)
+ project.update!(public_builds: false)
project.team.truncate
notification.pipeline_finished(pipeline)
end
@@ -2855,7 +2869,7 @@ RSpec.describe NotificationService, :mailer do
before do
group = create(:group)
- project.update(group: group)
+ project.update!(group: group)
create(:email, :confirmed, user: u_member, email: group_notification_email)
create(:notification_setting, user: u_member, source: group, notification_email: group_notification_email)
end
@@ -3089,37 +3103,51 @@ RSpec.describe NotificationService, :mailer do
subject.new_issue(issue, member)
end
- it 'still delivers email to admins' do
- member.update!(admin: true)
+ context 'with admin user' do
+ before do
+ member.update!(admin: true)
+ end
- expect(Notify).to receive(:new_issue_email).at_least(:once).with(member.id, issue.id, nil).and_call_original
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'still delivers email to admins' do
+ expect(Notify).to receive(:new_issue_email).at_least(:once).with(member.id, issue.id, nil).and_call_original
- subject.new_issue(issue, member)
+ subject.new_issue(issue, member)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'does not send an email' do
+ expect(Notify).not_to receive(:new_issue_email)
+
+ subject.new_issue(issue, member)
+ end
+ end
end
end
end
describe '#prometheus_alerts_fired' do
- let!(:project) { create(:project) }
- let!(:master) { create(:user) }
- let!(:developer) { create(:user) }
- let(:alert_attributes) { build(:alert_management_alert, project: project).attributes }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:master) { create(:user) }
+ let_it_be(:developer) { create(:user) }
+ let_it_be(:alert) { create(:alert_management_alert, project: project) }
before do
project.add_maintainer(master)
end
it 'sends the email to owners and masters' do
- expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, master.id, alert_attributes).and_call_original
- expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, project.owner.id, alert_attributes).and_call_original
- expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project.id, developer.id, alert_attributes)
+ expect(Notify).to receive(:prometheus_alert_fired_email).with(project, master, alert).and_call_original
+ expect(Notify).to receive(:prometheus_alert_fired_email).with(project, project.owner, alert).and_call_original
+ expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project, developer, alert)
- subject.prometheus_alerts_fired(project, [alert_attributes])
+ subject.prometheus_alerts_fired(project, [alert])
end
it_behaves_like 'project emails are disabled' do
let(:notification_target) { project }
- let(:notification_trigger) { subject.prometheus_alerts_fired(project, [alert_attributes]) }
+ let(:notification_trigger) { subject.prometheus_alerts_fired(project, [alert]) }
around do |example|
perform_enqueued_jobs { example.run }
@@ -3212,7 +3240,7 @@ RSpec.describe NotificationService, :mailer do
# with different notification settings
def build_group(project, visibility: :public)
group = create_nested_group(visibility)
- project.update(namespace_id: group.id)
+ project.update!(namespace_id: group.id)
# Group member: global=disabled, group=watch
@g_watcher ||= create_user_with_notification(:watch, 'group_watcher', project.group)
@@ -3277,11 +3305,11 @@ RSpec.describe NotificationService, :mailer do
end
def add_user_subscriptions(issuable)
- issuable.subscriptions.create(user: @unsubscribed_mentioned, project: project, subscribed: false)
- issuable.subscriptions.create(user: @subscriber, project: project, subscribed: true)
- issuable.subscriptions.create(user: @subscribed_participant, project: project, subscribed: true)
- issuable.subscriptions.create(user: @unsubscriber, project: project, subscribed: false)
+ issuable.subscriptions.create!(user: @unsubscribed_mentioned, project: project, subscribed: false)
+ issuable.subscriptions.create!(user: @subscriber, project: project, subscribed: true)
+ issuable.subscriptions.create!(user: @subscribed_participant, project: project, subscribed: true)
+ issuable.subscriptions.create!(user: @unsubscriber, project: project, subscribed: false)
# Make the watcher a subscriber to detect dupes
- issuable.subscriptions.create(user: @watcher_and_subscriber, project: project, subscribed: true)
+ issuable.subscriptions.create!(user: @watcher_and_subscriber, project: project, subscribed: true)
end
end
diff --git a/spec/services/packages/composer/version_parser_service_spec.rb b/spec/services/packages/composer/version_parser_service_spec.rb
index 904c75ab0a1..1a2f653c042 100644
--- a/spec/services/packages/composer/version_parser_service_spec.rb
+++ b/spec/services/packages/composer/version_parser_service_spec.rb
@@ -19,9 +19,11 @@ RSpec.describe Packages::Composer::VersionParserService do
nil | '1.7.x' | '1.7.x-dev'
'v1.0.0' | nil | '1.0.0'
'v1.0' | nil | '1.0'
+ 'v1.0.1+meta' | nil | '1.0.1+meta'
'1.0' | nil | '1.0'
'1.0.2' | nil | '1.0.2'
'1.0.2-beta2' | nil | '1.0.2-beta2'
+ '1.0.1+meta' | nil | '1.0.1+meta'
end
with_them do
diff --git a/spec/services/packages/conan/create_package_file_service_spec.rb b/spec/services/packages/conan/create_package_file_service_spec.rb
index 0e9cbba5fc1..bd6a91c883a 100644
--- a/spec/services/packages/conan/create_package_file_service_spec.rb
+++ b/spec/services/packages/conan/create_package_file_service_spec.rb
@@ -100,7 +100,7 @@ RSpec.describe Packages::Conan::CreatePackageFileService do
end
let(:tmp_object) do
- fog_connection.directories.new(key: 'packages').files.create(
+ fog_connection.directories.new(key: 'packages').files.create( # rubocop:disable Rails/SaveBang
key: "tmp/uploads/#{file_name}",
body: 'content'
)
diff --git a/spec/services/packages/create_event_service_spec.rb b/spec/services/packages/create_event_service_spec.rb
index 7e66b430a8c..55703e9127f 100644
--- a/spec/services/packages/create_event_service_spec.rb
+++ b/spec/services/packages/create_event_service_spec.rb
@@ -15,40 +15,92 @@ RSpec.describe Packages::CreateEventService do
subject { described_class.new(nil, user, params).execute }
describe '#execute' do
- shared_examples 'package event creation' do |originator_type, expected_scope|
- it 'creates the event' do
- expect { subject }.to change { Packages::Event.count }.by(1)
-
- expect(subject.originator_type).to eq(originator_type)
- expect(subject.originator).to eq(user&.id)
- expect(subject.event_scope).to eq(expected_scope)
- expect(subject.event_type).to eq(event_name)
+ shared_examples 'db package event creation' do |originator_type, expected_scope|
+ before do
+ allow(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event)
+ end
+
+ context 'with feature flag disable' do
+ before do
+ stub_feature_flags(collect_package_events: false)
+ end
+
+ it 'does not create an event object' do
+ expect { subject }.not_to change { Packages::Event.count }
+ end
+ end
+
+ context 'with feature flag enabled' do
+ before do
+ stub_feature_flags(collect_package_events: true)
+ end
+
+ it 'creates the event' do
+ expect { subject }.to change { Packages::Event.count }.by(1)
+
+ expect(subject.originator_type).to eq(originator_type)
+ expect(subject.originator).to eq(user&.id)
+ expect(subject.event_scope).to eq(expected_scope)
+ expect(subject.event_type).to eq(event_name)
+ end
+ end
+ end
+
+ shared_examples 'redis package event creation' do |originator_type, expected_scope|
+ context 'with feature flag disable' do
+ before do
+ stub_feature_flags(collect_package_events_redis: false)
+ end
+
+ it 'does not track the event' do
+ expect(::Gitlab::UsageDataCounters::HLLRedisCounter).not_to receive(:track_event)
+
+ subject
+ end
+ end
+
+ it 'tracks the event' do
+ expect(::Gitlab::UsageDataCounters::HLLRedisCounter).to receive(:track_event).with(user.id, Packages::Event.allowed_event_name(expected_scope, event_name, originator_type))
+
+ subject
end
end
context 'with a user' do
let(:user) { create(:user) }
- it_behaves_like 'package event creation', 'user', 'container'
+ it_behaves_like 'db package event creation', 'user', 'container'
+ it_behaves_like 'redis package event creation', 'user', 'container'
end
context 'with a deploy token' do
let(:user) { create(:deploy_token) }
- it_behaves_like 'package event creation', 'deploy_token', 'container'
+ it_behaves_like 'db package event creation', 'deploy_token', 'container'
+ it_behaves_like 'redis package event creation', 'deploy_token', 'container'
end
context 'with no user' do
let(:user) { nil }
- it_behaves_like 'package event creation', 'guest', 'container'
+ it_behaves_like 'db package event creation', 'guest', 'container'
end
context 'with a package as scope' do
- let(:user) { nil }
let(:scope) { create(:npm_package) }
- it_behaves_like 'package event creation', 'guest', 'npm'
+ context 'as guest' do
+ let(:user) { nil }
+
+ it_behaves_like 'db package event creation', 'guest', 'npm'
+ end
+
+ context 'with user' do
+ let(:user) { create(:user) }
+
+ it_behaves_like 'db package event creation', 'user', 'npm'
+ it_behaves_like 'redis package event creation', 'user', 'npm'
+ end
end
end
end
diff --git a/spec/services/packages/create_package_file_service_spec.rb b/spec/services/packages/create_package_file_service_spec.rb
index 93dde54916a..12fd1039d30 100644
--- a/spec/services/packages/create_package_file_service_spec.rb
+++ b/spec/services/packages/create_package_file_service_spec.rb
@@ -2,7 +2,10 @@
require 'spec_helper'
RSpec.describe Packages::CreatePackageFileService do
- let(:package) { create(:maven_package) }
+ let_it_be(:package) { create(:maven_package) }
+ let_it_be(:user) { create(:user) }
+
+ subject { described_class.new(package, params) }
describe '#execute' do
context 'with valid params' do
@@ -14,7 +17,7 @@ RSpec.describe Packages::CreatePackageFileService do
end
it 'creates a new package file' do
- package_file = described_class.new(package, params).execute
+ package_file = subject.execute
expect(package_file).to be_valid
expect(package_file.file_name).to eq('foo.jar')
@@ -29,9 +32,17 @@ RSpec.describe Packages::CreatePackageFileService do
end
it 'raises an error' do
- service = described_class.new(package, params)
+ expect { subject.execute }.to raise_error(ActiveRecord::RecordInvalid)
+ end
+ end
+
+ context 'with a build' do
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
+ let(:build) { double('build', pipeline: pipeline) }
+ let(:params) { { file: Tempfile.new, file_name: 'foo.jar', build: build } }
- expect { service.execute }.to raise_error(ActiveRecord::RecordInvalid)
+ it 'creates a build_info' do
+ expect { subject.execute }.to change { Packages::PackageFileBuildInfo.count }.by(1)
end
end
end
diff --git a/spec/services/packages/debian/extract_deb_metadata_service_spec.rb b/spec/services/packages/debian/extract_deb_metadata_service_spec.rb
new file mode 100644
index 00000000000..33059adf8a2
--- /dev/null
+++ b/spec/services/packages/debian/extract_deb_metadata_service_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::Debian::ExtractDebMetadataService do
+ subject { described_class.new(file_path) }
+
+ let(:file_name) { 'libsample0_1.2.3~alpha2_amd64.deb' }
+ let(:file_path) { "spec/fixtures/packages/debian/#{file_name}" }
+
+ context 'with correct file' do
+ it 'return as expected' do
+ expected = {
+ 'Package': 'libsample0',
+ 'Source': 'sample',
+ 'Version': '1.2.3~alpha2',
+ 'Architecture': 'amd64',
+ 'Maintainer': 'John Doe <john.doe@example.com>',
+ 'Installed-Size': '7',
+ 'Section': 'libs',
+ 'Priority': 'optional',
+ 'Multi-Arch': 'same',
+ 'Homepage': 'https://gitlab.com/',
+ 'Description': "Some mostly empty lib\nUsed in GitLab tests.\n\nTesting another paragraph."
+ }
+
+ expect(subject.execute).to eq expected
+ end
+ end
+
+ context 'with incorrect file' do
+ let(:file_name) { 'README.md' }
+
+ it 'raise error' do
+ expect {subject.execute}.to raise_error(described_class::CommandFailedError, /is not a Debian format archive/i)
+ end
+ end
+end
diff --git a/spec/services/packages/debian/parse_debian822_service_spec.rb b/spec/services/packages/debian/parse_debian822_service_spec.rb
new file mode 100644
index 00000000000..b67daca89c4
--- /dev/null
+++ b/spec/services/packages/debian/parse_debian822_service_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe Packages::Debian::ParseDebian822Service do
+ subject { described_class.new(input) }
+
+ context 'with dpkg-deb --field output' do
+ let(:input) do
+ <<~HEREDOC
+ Package: libsample0
+ Source: sample
+ Version: 1.2.3~alpha2
+ Architecture: amd64
+ Maintainer: John Doe <john.doe@example.com>
+ Installed-Size: 9
+ Section: libs
+ Priority: optional
+ Multi-Arch: same
+ Homepage: https://gitlab.com/
+ Description: Some mostly empty lib
+ Used in GitLab tests.
+ .
+ Testing another paragraph.
+ HEREDOC
+ end
+
+ it 'return as expected, preserving order' do
+ expected = {
+ 'Package: libsample0' => {
+ 'Package': 'libsample0',
+ 'Source': 'sample',
+ 'Version': '1.2.3~alpha2',
+ 'Architecture': 'amd64',
+ 'Maintainer': 'John Doe <john.doe@example.com>',
+ 'Installed-Size': '9',
+ 'Section': 'libs',
+ 'Priority': 'optional',
+ 'Multi-Arch': 'same',
+ 'Homepage': 'https://gitlab.com/',
+ 'Description': "Some mostly empty lib\nUsed in GitLab tests.\n\nTesting another paragraph."
+ }
+ }
+
+ expect(subject.execute.to_s).to eq(expected.to_s)
+ end
+ end
+
+ context 'with control file' do
+ let(:input) { fixture_file('packages/debian/sample/debian/control') }
+
+ it 'return as expected, preserving order' do
+ expected = {
+ 'Source: sample' => {
+ 'Source': 'sample',
+ 'Priority': 'optional',
+ 'Maintainer': 'John Doe <john.doe@example.com>',
+ 'Build-Depends': 'debhelper-compat (= 13)',
+ 'Standards-Version': '4.5.0',
+ 'Section': 'libs',
+ 'Homepage': 'https://gitlab.com/',
+ # 'Vcs-Browser': 'https://salsa.debian.org/debian/sample-1.2.3',
+ # '#Vcs-Git': 'https://salsa.debian.org/debian/sample-1.2.3.git',
+ 'Rules-Requires-Root': 'no'
+ },
+ 'Package: sample-dev' => {
+ 'Package': 'sample-dev',
+ 'Section': 'libdevel',
+ 'Architecture': 'any',
+ 'Multi-Arch': 'same',
+ 'Depends': 'libsample0 (= ${binary:Version}), ${misc:Depends}',
+ 'Description': "Some mostly empty developpement files\nUsed in GitLab tests.\n\nTesting another paragraph."
+ },
+ 'Package: libsample0' => {
+ 'Package': 'libsample0',
+ 'Architecture': 'any',
+ 'Multi-Arch': 'same',
+ 'Depends': '${shlibs:Depends}, ${misc:Depends}',
+ 'Description': "Some mostly empty lib\nUsed in GitLab tests.\n\nTesting another paragraph."
+ },
+ 'Package: sample-udeb' => {
+ 'Package': 'sample-udeb',
+ 'Package-Type': 'udeb',
+ 'Architecture': 'any',
+ 'Depends': 'installed-base',
+ 'Description': 'Some mostly empty udeb'
+ }
+ }
+
+ expect(subject.execute.to_s).to eq(expected.to_s)
+ end
+ end
+
+ context 'with empty input' do
+ let(:input) { '' }
+
+ it 'return a empty hash' do
+ expect(subject.execute).to eq({})
+ end
+ end
+
+ context 'with unexpected continuation line' do
+ let(:input) { ' continuation' }
+
+ it 'raise error' do
+ expect {subject.execute}.to raise_error(described_class::InvalidDebian822Error, 'Parse error. Unexpected continuation line')
+ end
+ end
+
+ context 'with duplicate field' do
+ let(:input) do
+ <<~HEREDOC
+ Package: libsample0
+ Source: sample
+ Source: sample
+ HEREDOC
+ end
+
+ it 'raise error' do
+ expect {subject.execute}.to raise_error(described_class::InvalidDebian822Error, "Duplicate field 'Source' in section 'Package: libsample0'")
+ end
+ end
+
+ context 'with incorrect input' do
+ let(:input) do
+ <<~HEREDOC
+ Hello
+ HEREDOC
+ end
+
+ it 'raise error' do
+ expect {subject.execute}.to raise_error(described_class::InvalidDebian822Error, 'Parse error on line Hello')
+ end
+ end
+
+ context 'with duplicate section' do
+ let(:input) do
+ <<~HEREDOC
+ Package: libsample0
+
+ Package: libsample0
+ HEREDOC
+ end
+
+ it 'raise error' do
+ expect {subject.execute}.to raise_error(described_class::InvalidDebian822Error, "Duplicate section 'Package: libsample0'")
+ end
+ end
+end
diff --git a/spec/services/packages/generic/create_package_file_service_spec.rb b/spec/services/packages/generic/create_package_file_service_spec.rb
index 0ae109ef996..907483e3d7f 100644
--- a/spec/services/packages/generic/create_package_file_service_spec.rb
+++ b/spec/services/packages/generic/create_package_file_service_spec.rb
@@ -5,6 +5,8 @@ require 'spec_helper'
RSpec.describe Packages::Generic::CreatePackageFileService do
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
+ let(:build) { double('build', pipeline: pipeline) }
describe '#execute' do
let(:sha256) { '440e5e148a25331bbd7991575f7d54933c0ebf6cc735a18ee5066ac1381bb590' }
@@ -16,7 +18,8 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
package_name: 'mypackage',
package_version: '0.0.1',
file: file,
- file_name: 'myfile.tar.gz.1'
+ file_name: 'myfile.tar.gz.1',
+ build: build
}
end
@@ -41,6 +44,7 @@ RSpec.describe Packages::Generic::CreatePackageFileService do
service = described_class.new(project, user, params)
expect { service.execute }.to change { package.package_files.count }.by(1)
+ .and change { Packages::PackageFileBuildInfo.count }.by(1)
package_file = package.package_files.last
aggregate_failures do
diff --git a/spec/services/packages/generic/find_or_create_package_service_spec.rb b/spec/services/packages/generic/find_or_create_package_service_spec.rb
index 5a9b8b03279..a045cb36418 100644
--- a/spec/services/packages/generic/find_or_create_package_service_spec.rb
+++ b/spec/services/packages/generic/find_or_create_package_service_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Packages::Generic::FindOrCreatePackageService do
expect(package.creator).to eq(user)
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
- expect(package.build_info).to be_nil
+ expect(package.original_build_info).to be_nil
end
end
@@ -42,7 +42,7 @@ RSpec.describe Packages::Generic::FindOrCreatePackageService do
expect(package.creator).to eq(user)
expect(package.name).to eq('mypackage')
expect(package.version).to eq('0.0.1')
- expect(package.build_info.pipeline).to eq(ci_build.pipeline)
+ expect(package.original_build_info.pipeline).to eq(ci_build.pipeline)
end
end
end
@@ -60,7 +60,7 @@ RSpec.describe Packages::Generic::FindOrCreatePackageService do
expect(found_package).to eq(package)
end.not_to change { project.packages.generic.count }
- expect(package.reload.build_info).to be_nil
+ expect(package.reload.original_build_info).to be_nil
end
end
@@ -68,7 +68,7 @@ RSpec.describe Packages::Generic::FindOrCreatePackageService do
let(:pipeline) { create(:ci_pipeline, project: project) }
before do
- package.create_build_info!(pipeline: pipeline)
+ package.build_infos.create!(pipeline: pipeline)
end
it 'finds the package and does not change package build info even if build is provided' do
@@ -80,7 +80,7 @@ RSpec.describe Packages::Generic::FindOrCreatePackageService do
expect(found_package).to eq(package)
end.not_to change { project.packages.generic.count }
- expect(package.reload.build_info.pipeline).to eq(pipeline)
+ expect(package.reload.original_build_info.pipeline).to eq(pipeline)
end
end
end
diff --git a/spec/services/packages/maven/create_package_service_spec.rb b/spec/services/packages/maven/create_package_service_spec.rb
index 7ec368aa00f..11bf00c1399 100644
--- a/spec/services/packages/maven/create_package_service_spec.rb
+++ b/spec/services/packages/maven/create_package_service_spec.rb
@@ -33,8 +33,6 @@ RSpec.describe Packages::Maven::CreatePackageService do
expect(package.maven_metadatum.app_version).to eq(version)
end
- it_behaves_like 'assigns build to package'
-
it_behaves_like 'assigns the package creator'
end
diff --git a/spec/services/packages/maven/find_or_create_package_service_spec.rb b/spec/services/packages/maven/find_or_create_package_service_spec.rb
index 4406e4037e2..2eaad7db445 100644
--- a/spec/services/packages/maven/find_or_create_package_service_spec.rb
+++ b/spec/services/packages/maven/find_or_create_package_service_spec.rb
@@ -10,11 +10,12 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
let(:version) { '1.0.0' }
let(:file_name) { 'test.jar' }
let(:param_path) { "#{path}/#{version}" }
+ let(:params) { { path: param_path, file_name: file_name } }
describe '#execute' do
using RSpec::Parameterized::TableSyntax
- subject { described_class.new(project, user, { path: param_path, file_name: file_name }).execute }
+ subject { described_class.new(project, user, params).execute }
RSpec.shared_examples 'reuse existing package' do
it { expect { subject}.not_to change { Packages::Package.count } }
@@ -23,7 +24,7 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
end
RSpec.shared_examples 'create package' do
- it { expect { subject}.to change { Packages::Package.count }.by(1) }
+ it { expect { subject }.to change { Packages::Package.count }.by(1) }
it 'sets the proper name and version' do
pkg = subject
@@ -31,6 +32,8 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
expect(pkg.name).to eq(path)
expect(pkg.version).to eq(version)
end
+
+ it_behaves_like 'assigns build to package'
end
context 'path with version' do
@@ -77,5 +80,15 @@ RSpec.describe Packages::Maven::FindOrCreatePackageService do
end
end
end
+
+ context 'with a build' do
+ let_it_be(:pipeline) { create(:ci_pipeline, user: user) }
+ let(:build) { double('build', pipeline: pipeline) }
+ let(:params) { { path: param_path, file_name: file_name, build: build } }
+
+ it 'creates a build_info' do
+ expect { subject }.to change { Packages::BuildInfo.count }.by(1)
+ end
+ end
end
end
diff --git a/spec/services/packages/npm/create_package_service_spec.rb b/spec/services/packages/npm/create_package_service_spec.rb
index c8431c640da..6db3777cde8 100644
--- a/spec/services/packages/npm/create_package_service_spec.rb
+++ b/spec/services/packages/npm/create_package_service_spec.rb
@@ -48,7 +48,16 @@ RSpec.describe Packages::Npm::CreatePackageService do
context 'scoped package' do
it_behaves_like 'valid package'
- it_behaves_like 'assigns build to package'
+ context 'with build info' do
+ let(:job) { create(:ci_build, user: user) }
+ let(:params) { super().merge(build: job) }
+
+ it_behaves_like 'assigns build to package'
+
+ it 'creates a package file build info' do
+ expect { subject }.to change { Packages::PackageFileBuildInfo.count }.by(1)
+ end
+ end
end
context 'invalid package name' do
diff --git a/spec/services/pages/destroy_deployments_service_spec.rb b/spec/services/pages/destroy_deployments_service_spec.rb
new file mode 100644
index 00000000000..0f8e8b6573e
--- /dev/null
+++ b/spec/services/pages/destroy_deployments_service_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Pages::DestroyDeploymentsService do
+ let(:project) { create(:project) }
+ let!(:old_deployments) { create_list(:pages_deployment, 2, project: project) }
+ let!(:last_deployment) { create(:pages_deployment, project: project) }
+ let!(:newer_deployment) { create(:pages_deployment, project: project) }
+ let!(:deployment_from_another_project) { create(:pages_deployment) }
+
+ it 'destroys all deployments of the project' do
+ expect do
+ described_class.new(project).execute
+ end.to change { PagesDeployment.count }.by(-4)
+
+ expect(deployment_from_another_project.reload).to be
+ end
+
+ it 'destroy only deployments older than last deployment if it is provided' do
+ expect do
+ described_class.new(project, last_deployment.id).execute
+ end.to change { PagesDeployment.count }.by(-2)
+
+ expect(last_deployment.reload).to be
+ expect(newer_deployment.reload).to be
+ expect(deployment_from_another_project.reload).to be
+ end
+end
diff --git a/spec/services/personal_access_tokens/create_service_spec.rb b/spec/services/personal_access_tokens/create_service_spec.rb
index 475ade95948..842bebd13a1 100644
--- a/spec/services/personal_access_tokens/create_service_spec.rb
+++ b/spec/services/personal_access_tokens/create_service_spec.rb
@@ -3,21 +3,59 @@
require 'spec_helper'
RSpec.describe PersonalAccessTokens::CreateService do
+ shared_examples_for 'a successfully created token' do
+ it 'creates personal access token record' do
+ expect(subject.success?).to be true
+ expect(token.name).to eq(params[:name])
+ expect(token.impersonation).to eq(params[:impersonation])
+ expect(token.scopes).to eq(params[:scopes])
+ expect(token.expires_at).to eq(params[:expires_at])
+ expect(token.user).to eq(user)
+ end
+
+ it 'logs the event' do
+ expect(Gitlab::AppLogger).to receive(:info).with(/PAT CREATION: created_by: '#{current_user.username}', created_for: '#{user.username}', token_id: '\d+'/)
+
+ subject
+ end
+ end
+
+ shared_examples_for 'an unsuccessfully created token' do
+ it { expect(subject.success?).to be false }
+ it { expect(subject.message).to eq('Not permitted to create') }
+ it { expect(token).to be_nil }
+ end
+
describe '#execute' do
- context 'with valid params' do
- it 'creates personal access token record' do
- user = create(:user)
- params = { name: 'Test token', impersonation: true, scopes: [:api], expires_at: Date.today + 1.month }
-
- response = described_class.new(user, params).execute
- personal_access_token = response.payload[:personal_access_token]
-
- expect(response.success?).to be true
- expect(personal_access_token.name).to eq(params[:name])
- expect(personal_access_token.impersonation).to eq(params[:impersonation])
- expect(personal_access_token.scopes).to eq(params[:scopes])
- expect(personal_access_token.expires_at).to eq(params[:expires_at])
- expect(personal_access_token.user).to eq(user)
+ subject { service.execute }
+
+ let(:current_user) { create(:user) }
+ let(:user) { create(:user) }
+ let(:params) { { name: 'Test token', impersonation: false, scopes: [:api], expires_at: Date.today + 1.month } }
+ let(:service) { described_class.new(current_user: current_user, target_user: user, params: params) }
+ let(:token) { subject.payload[:personal_access_token] }
+
+ context 'when current_user is an administrator' do
+ let(:current_user) { create(:admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'a successfully created token'
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'an unsuccessfully created token'
+ end
+ end
+
+ context 'when current_user is not an administrator' do
+ context 'target_user is not the same as current_user' do
+ it_behaves_like 'an unsuccessfully created token'
+ end
+
+ context 'target_user is same as current_user' do
+ let(:current_user) { user }
+
+ it_behaves_like 'a successfully created token'
end
end
end
diff --git a/spec/services/personal_access_tokens/revoke_service_spec.rb b/spec/services/personal_access_tokens/revoke_service_spec.rb
index 5afa43cef76..a25484e218e 100644
--- a/spec/services/personal_access_tokens/revoke_service_spec.rb
+++ b/spec/services/personal_access_tokens/revoke_service_spec.rb
@@ -6,6 +6,11 @@ RSpec.describe PersonalAccessTokens::RevokeService do
shared_examples_for 'a successfully revoked token' do
it { expect(subject.success?).to be true }
it { expect(service.token.revoked?).to be true }
+ it 'logs the event' do
+ expect(Gitlab::AppLogger).to receive(:info).with(/PAT REVOCATION: revoked_by: '#{current_user.username}', revoked_for: '#{token.user.username}', token_id: '\d+'/)
+
+ subject
+ end
end
shared_examples_for 'an unsuccessfully revoked token' do
@@ -19,10 +24,19 @@ RSpec.describe PersonalAccessTokens::RevokeService do
let(:service) { described_class.new(current_user, token: token) }
context 'when current_user is an administrator' do
- let_it_be(:current_user) { create(:admin) }
- let_it_be(:token) { create(:personal_access_token) }
+ context 'when admin mode is enabled', :enable_admin_mode do
+ let_it_be(:current_user) { create(:admin) }
+ let_it_be(:token) { create(:personal_access_token) }
+
+ it_behaves_like 'a successfully revoked token'
+ end
+
+ context 'when admin mode is disabled' do
+ let_it_be(:current_user) { create(:admin) }
+ let_it_be(:token) { create(:personal_access_token) }
- it_behaves_like 'a successfully revoked token'
+ it_behaves_like 'an unsuccessfully revoked token'
+ end
end
context 'when current_user is not an administrator' do
diff --git a/spec/services/post_receive_service_spec.rb b/spec/services/post_receive_service_spec.rb
index c726e1851a7..7c4b7f51cc3 100644
--- a/spec/services/post_receive_service_spec.rb
+++ b/spec/services/post_receive_service_spec.rb
@@ -232,6 +232,49 @@ RSpec.describe PostReceiveService do
end
end
+ context "broadcast message has a target_path" do
+ let!(:older_scoped_message) do
+ create(:broadcast_message, message: "Old top secret", target_path: "/company/sekrit-project")
+ end
+
+ let!(:latest_scoped_message) do
+ create(:broadcast_message, message: "Top secret", target_path: "/company/sekrit-project")
+ end
+
+ let!(:unscoped_message) do
+ create(:broadcast_message, message: "Hi")
+ end
+
+ context "no project path matches" do
+ it "does not output the scoped broadcast messages" do
+ expect(subject).not_to include(build_alert_message(older_scoped_message.message))
+ expect(subject).not_to include(build_alert_message(latest_scoped_message.message))
+ end
+
+ it "does output another message that doesn't have a target_path" do
+ expect(subject).to include(build_alert_message(unscoped_message.message))
+ end
+ end
+
+ context "project path matches" do
+ before do
+ allow(project).to receive(:full_path).and_return("/company/sekrit-project")
+ end
+
+ it "does output the latest scoped broadcast message" do
+ expect(subject).to include(build_alert_message(latest_scoped_message.message))
+ end
+
+ it "does not output the older scoped broadcast message" do
+ expect(subject).not_to include(build_alert_message(older_scoped_message.message))
+ end
+
+ it "does not output another message that doesn't have a target_path" do
+ expect(subject).not_to include(build_alert_message(unscoped_message.message))
+ end
+ end
+ end
+
context 'with a redirected data' do
it 'returns redirected message on the response' do
project_moved = Gitlab::Checks::ProjectMoved.new(project.repository, user, 'http', 'foo/baz')
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index 809b12910a1..4674f614cf1 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -34,13 +34,11 @@ RSpec.describe Projects::Alerting::NotifyService do
let(:payload) { ActionController::Parameters.new(payload_raw).permit! }
- subject { service.execute(token) }
-
- context 'with activated Alerts Service' do
- let_it_be_with_reload(:alerts_service) { create(:alerts_service, project: project) }
+ subject { service.execute(token, nil) }
+ shared_examples 'notifcations are handled correctly' do
context 'with valid token' do
- let(:token) { alerts_service.token }
+ let(:token) { integration.token }
let(:incident_management_setting) { double(send_email?: email_enabled, create_issue?: issue_enabled, auto_close_incident?: auto_close_enabled) }
let(:email_enabled) { false }
let(:issue_enabled) { false }
@@ -131,6 +129,12 @@ RSpec.describe Projects::Alerting::NotifyService do
it { expect { subject }.to change { issue.reload.state }.from('opened').to('closed') }
it { expect { subject }.to change(ResourceStateEvent, :count).by(1) }
end
+
+ context 'with issue enabled' do
+ let(:issue_enabled) { true }
+
+ it_behaves_like 'does not process incident issues'
+ end
end
end
@@ -197,7 +201,7 @@ RSpec.describe Projects::Alerting::NotifyService do
it 'creates a system note corresponding to alert creation' do
expect { subject }.to change(Note, :count).by(1)
- expect(Note.last.note).to include('Generic Alert Endpoint')
+ expect(Note.last.note).to include(source)
end
end
end
@@ -247,10 +251,20 @@ RSpec.describe Projects::Alerting::NotifyService do
it_behaves_like 'does not process incident issues due to error', http_status: :unauthorized
it_behaves_like 'does not an create alert management alert'
end
+ end
+
+ context 'with an HTTP Integration' do
+ let_it_be_with_reload(:integration) { create(:alert_management_http_integration, project: project) }
+
+ subject { service.execute(token, integration) }
+
+ it_behaves_like 'notifcations are handled correctly' do
+ let(:source) { integration.name }
+ end
- context 'with deactivated Alerts Service' do
+ context 'with deactivated HTTP Integration' do
before do
- alerts_service.update!(active: false)
+ integration.update!(active: false)
end
it_behaves_like 'does not process incident issues due to error', http_status: :forbidden
diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb
index aff1aa41091..ef7741c2d0f 100644
--- a/spec/services/projects/autocomplete_service_spec.rb
+++ b/spec/services/projects/autocomplete_service_spec.rb
@@ -79,14 +79,28 @@ RSpec.describe Projects::AutocompleteService do
expect(issues.count).to eq 3
end
- it 'lists all project issues for admin' do
- autocomplete = described_class.new(project, admin)
- issues = autocomplete.issues.map(&:iid)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'lists all project issues for admin', :enable_admin_mode do
+ autocomplete = described_class.new(project, admin)
+ issues = autocomplete.issues.map(&:iid)
+
+ expect(issues).to include issue.iid
+ expect(issues).to include security_issue_1.iid
+ expect(issues).to include security_issue_2.iid
+ expect(issues.count).to eq 3
+ end
+ end
- expect(issues).to include issue.iid
- expect(issues).to include security_issue_1.iid
- expect(issues).to include security_issue_2.iid
- expect(issues.count).to eq 3
+ context 'when admin mode is disabled' do
+ it 'does not list project confidential issues for admin' do
+ autocomplete = described_class.new(project, admin)
+ issues = autocomplete.issues.map(&:iid)
+
+ expect(issues).to include issue.iid
+ expect(issues).not_to include security_issue_1.iid
+ expect(issues).not_to include security_issue_2.iid
+ expect(issues.count).to eq 1
+ end
end
end
end
diff --git a/spec/services/projects/cleanup_service_spec.rb b/spec/services/projects/cleanup_service_spec.rb
index 7c28b729e84..6fd29813d98 100644
--- a/spec/services/projects/cleanup_service_spec.rb
+++ b/spec/services/projects/cleanup_service_spec.rb
@@ -3,14 +3,84 @@
require 'spec_helper'
RSpec.describe Projects::CleanupService do
- let(:project) { create(:project, :repository, bfg_object_map: fixture_file_upload('spec/fixtures/bfg_object_map.txt')) }
- let(:object_map) { project.bfg_object_map }
+ subject(:service) { described_class.new(project) }
- let(:cleaner) { service.__send__(:repository_cleaner) }
+ describe '.enqueue' do
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:user) { create(:user) }
- subject(:service) { described_class.new(project) }
+ let(:object_map_file) { fixture_file_upload('spec/fixtures/bfg_object_map.txt') }
+
+ subject(:enqueue) { described_class.enqueue(project, user, object_map_file) }
+
+ it 'makes the repository read-only' do
+ expect { enqueue }
+ .to change(project, :repository_read_only?)
+ .from(false)
+ .to(true)
+ end
+
+ it 'sets the bfg_object_map of the project' do
+ enqueue
+
+ expect(project.bfg_object_map.read).to eq(object_map_file.read)
+ end
+
+ it 'enqueues a RepositoryCleanupWorker' do
+ enqueue
+
+ expect(RepositoryCleanupWorker.jobs.count).to eq(1)
+ end
+
+ it 'returns success' do
+ expect(enqueue[:status]).to eq(:success)
+ end
+
+ it 'returns an error if making the repository read-only fails' do
+ project.set_repository_read_only!
+
+ expect(enqueue[:status]).to eq(:error)
+ end
+
+ it 'returns an error if updating the project fails' do
+ expect_next_instance_of(Projects::UpdateService) do |service|
+ expect(service).to receive(:execute).and_return(status: :error)
+ end
+
+ expect(enqueue[:status]).to eq(:error)
+ expect(project.reload.repository_read_only?).to be_falsy
+ end
+ end
+
+ describe '.cleanup_after' do
+ let(:project) { create(:project, :repository, bfg_object_map: fixture_file_upload('spec/fixtures/bfg_object_map.txt')) }
+
+ subject(:cleanup_after) { described_class.cleanup_after(project) }
+
+ before do
+ project.set_repository_read_only!
+ end
+
+ it 'sets the repository read-write' do
+ expect { cleanup_after }.to change(project, :repository_read_only?).from(true).to(false)
+ end
+
+ it 'removes the BFG object map' do
+ cleanup_after
+
+ expect(project.bfg_object_map).not_to be_exist
+ end
+ end
describe '#execute' do
+ let(:project) { create(:project, :repository, bfg_object_map: fixture_file_upload('spec/fixtures/bfg_object_map.txt')) }
+ let(:object_map) { project.bfg_object_map }
+ let(:cleaner) { service.__send__(:repository_cleaner) }
+
+ before do
+ project.set_repository_read_only!
+ end
+
it 'runs the apply_bfg_object_map_stream gitaly RPC' do
expect(cleaner).to receive(:apply_bfg_object_map_stream).with(kind_of(IO))
@@ -19,7 +89,7 @@ RSpec.describe Projects::CleanupService do
it 'runs garbage collection on the repository' do
expect_next_instance_of(GitGarbageCollectWorker) do |worker|
- expect(worker).to receive(:perform).with(project.id, :gc, "project_cleanup:gc:#{project.id}")
+ expect(worker).to receive(:perform).with(project.id, :prune, "project_cleanup:gc:#{project.id}")
end
service.execute
@@ -37,6 +107,13 @@ RSpec.describe Projects::CleanupService do
expect(object_map.exists?).to be_falsy
end
+ it 'makes the repository read-write again' do
+ expect { service.execute }
+ .to change(project, :repository_read_only?)
+ .from(true)
+ .to(false)
+ end
+
context 'with a tainted merge request diff' do
let(:merge_request) { create(:merge_request, source_project: project, target_project: project) }
let(:diff) { merge_request.merge_request_diff }
diff --git a/spec/services/projects/container_repository/delete_tags_service_spec.rb b/spec/services/projects/container_repository/delete_tags_service_spec.rb
index c3ae26b1f05..a012ec29be5 100644
--- a/spec/services/projects/container_repository/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/delete_tags_service_spec.rb
@@ -27,13 +27,17 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
end
end
- RSpec.shared_examples 'logging an error response' do |message: 'could not delete tags'|
+ RSpec.shared_examples 'logging an error response' do |message: 'could not delete tags', extra_log: {}|
it 'logs an error message' do
- expect(service).to receive(:log_error).with(
- service_class: 'Projects::ContainerRepository::DeleteTagsService',
- message: message,
- container_repository_id: repository.id
- )
+ log_data = {
+ service_class: 'Projects::ContainerRepository::DeleteTagsService',
+ message: message,
+ container_repository_id: repository.id
+ }
+
+ log_data.merge!(extra_log) if extra_log.any?
+
+ expect(service).to receive(:log_error).with(log_data)
subject
end
@@ -115,7 +119,7 @@ RSpec.describe Projects::ContainerRepository::DeleteTagsService do
it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
- it_behaves_like 'logging an error response', message: 'timeout while deleting tags'
+ it_behaves_like 'logging an error response', message: 'timeout while deleting tags', extra_log: { deleted_tags_count: 0 }
end
end
end
diff --git a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
index 3bbcec8775e..988971171fc 100644
--- a/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
+++ b/spec/services/projects/container_repository/gitlab/delete_tags_service_spec.rb
@@ -67,7 +67,7 @@ RSpec.describe Projects::ContainerRepository::Gitlab::DeleteTagsService do
stub_delete_reference_requests('A' => 200)
end
- it { is_expected.to include(status: :error, message: 'timeout while deleting tags') }
+ it { is_expected.to eq(status: :error, message: 'timeout while deleting tags', deleted: ['A']) }
it 'tracks the exception' do
expect(::Gitlab::ErrorTracking)
diff --git a/spec/services/projects/create_service_spec.rb b/spec/services/projects/create_service_spec.rb
index d959cc87901..6c0e6654622 100644
--- a/spec/services/projects/create_service_spec.rb
+++ b/spec/services/projects/create_service_spec.rb
@@ -72,14 +72,25 @@ RSpec.describe Projects::CreateService, '#execute' do
end
context "admin creates project with other user's namespace_id" do
- it 'sets the correct permissions' do
- admin = create(:admin)
- project = create_project(admin, opts)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'sets the correct permissions' do
+ admin = create(:admin)
+ project = create_project(admin, opts)
- expect(project).to be_persisted
- expect(project.owner).to eq(user)
- expect(project.team.maintainers).to contain_exactly(user)
- expect(project.namespace).to eq(user.namespace)
+ expect(project).to be_persisted
+ expect(project.owner).to eq(user)
+ expect(project.team.maintainers).to contain_exactly(user)
+ expect(project.namespace).to eq(user.namespace)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'is not allowed' do
+ admin = create(:admin)
+ project = create_project(admin, opts)
+
+ expect(project).not_to be_persisted
+ end
end
end
@@ -336,7 +347,15 @@ RSpec.describe Projects::CreateService, '#execute' do
)
end
- it 'allows a restricted visibility level for admins' do
+ it 'does not allow a restricted visibility level for admins when admin mode is disabled' do
+ admin = create(:admin)
+ project = create_project(admin, opts)
+
+ expect(project.errors.any?).to be(true)
+ expect(project.saved?).to be_falsey
+ end
+
+ it 'allows a restricted visibility level for admins when admin mode is enabled', :enable_admin_mode do
admin = create(:admin)
project = create_project(admin, opts)
diff --git a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
index cfe8e863223..1b829df6e6a 100644
--- a/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
+++ b/spec/services/projects/lfs_pointers/lfs_download_service_spec.rb
@@ -241,18 +241,6 @@ RSpec.describe Projects::LfsPointers::LfsDownloadService do
context 'and first fragments are the same' do
let(:lfs_content) { existing_lfs_object.file.read }
- context 'when lfs_link_existing_object feature flag disabled' do
- before do
- stub_feature_flags(lfs_link_existing_object: false)
- end
-
- it 'does not call link_existing_lfs_object!' do
- expect(subject).not_to receive(:link_existing_lfs_object!)
-
- subject.execute
- end
- end
-
it 'returns success' do
expect(subject.execute).to eq({ status: :success })
end
diff --git a/spec/services/projects/update_pages_service_spec.rb b/spec/services/projects/update_pages_service_spec.rb
index d3eb84a3137..d2c6c0eb971 100644
--- a/spec/services/projects/update_pages_service_spec.rb
+++ b/spec/services/projects/update_pages_service_spec.rb
@@ -27,7 +27,7 @@ RSpec.describe Projects::UpdatePagesService do
context 'for new artifacts' do
context "for a valid job" do
- let!(:artifacts_archive) { create(:ci_job_artifact, file: file, job: build) }
+ let!(:artifacts_archive) { create(:ci_job_artifact, :correct_checksum, file: file, job: build) }
before do
create(:ci_job_artifact, file_type: :metadata, file_format: :gzip, file: metadata, job: build)
@@ -66,9 +66,45 @@ RSpec.describe Projects::UpdatePagesService do
expect(deployment.size).to eq(file.size)
expect(deployment.file).to be
+ expect(deployment.file_count).to eq(3)
+ expect(deployment.file_sha256).to eq(artifacts_archive.file_sha256)
expect(project.pages_metadatum.reload.pages_deployment_id).to eq(deployment.id)
end
+ it 'does not fail if pages_metadata is absent' do
+ project.pages_metadatum.destroy!
+ project.reload
+
+ expect do
+ expect(execute).to eq(:success)
+ end.to change { project.pages_deployments.count }.by(1)
+
+ expect(project.pages_metadatum.reload.pages_deployment).to eq(project.pages_deployments.last)
+ end
+
+ context 'when there is an old pages deployment' do
+ let!(:old_deployment_from_another_project) { create(:pages_deployment) }
+ let!(:old_deployment) { create(:pages_deployment, project: project) }
+
+ it 'schedules a destruction of older deployments' do
+ expect(DestroyPagesDeploymentsWorker).to(
+ receive(:perform_in).with(described_class::OLD_DEPLOYMENTS_DESTRUCTION_DELAY,
+ project.id,
+ instance_of(Integer))
+ )
+
+ execute
+ end
+
+ it 'removes older deployments', :sidekiq_inline do
+ expect do
+ execute
+ end.not_to change { PagesDeployment.count } # it creates one and deletes one
+
+ expect(PagesDeployment.find_by_id(old_deployment.id)).to be_nil
+ end
+ end
+
it 'does not create deployment when zip_pages_deployments feature flag is disabled' do
stub_feature_flags(zip_pages_deployments: false)
@@ -188,6 +224,25 @@ RSpec.describe Projects::UpdatePagesService do
end
end
+ # this situation should never happen in real life because all new archives have sha256
+ # and we only use new archives
+ # this test is here just to clarify that this behavior is intentional
+ context 'when artifacts archive does not have sha256' do
+ let!(:artifacts_archive) { create(:ci_job_artifact, file: file, job: build) }
+
+ before do
+ create(:ci_job_artifact, file_type: :metadata, file_format: :gzip, file: metadata, job: build)
+
+ build.reload
+ end
+
+ it 'fails with exception raised' do
+ expect do
+ execute
+ end.to raise_error("Validation failed: File sha256 can't be blank")
+ end
+ end
+
it 'fails to remove project pages when no pages is deployed' do
expect(PagesWorker).not_to receive(:perform_in)
expect(project.pages_deployed?).to be_falsey
@@ -210,7 +265,7 @@ RSpec.describe Projects::UpdatePagesService do
file = fixture_file_upload('spec/fixtures/pages.zip')
metafile = fixture_file_upload('spec/fixtures/pages.zip.meta')
- create(:ci_job_artifact, :archive, file: file, job: build)
+ create(:ci_job_artifact, :archive, :correct_checksum, file: file, job: build)
create(:ci_job_artifact, :metadata, file: metafile, job: build)
allow(build).to receive(:artifacts_metadata_entry)
diff --git a/spec/services/projects/update_repository_storage_service_spec.rb b/spec/services/projects/update_repository_storage_service_spec.rb
index 0fcd14f3bc9..123f604e7a4 100644
--- a/spec/services/projects/update_repository_storage_service_spec.rb
+++ b/spec/services/projects/update_repository_storage_service_spec.rb
@@ -168,6 +168,24 @@ RSpec.describe Projects::UpdateRepositoryStorageService do
end
end
+ context 'project with no repositories' do
+ let(:project) { create(:project) }
+ let(:repository_storage_move) { create(:project_repository_storage_move, :scheduled, project: project, destination_storage_name: 'test_second_storage') }
+
+ it 'updates the database' do
+ allow(Gitlab::GitalyClient).to receive(:filesystem_id).with('default').and_call_original
+ allow(Gitlab::GitalyClient).to receive(:filesystem_id).with('test_second_storage').and_return(SecureRandom.uuid)
+
+ result = subject.execute
+ project.reload
+
+ expect(result).to be_success
+ expect(project).not_to be_repository_read_only
+ expect(project.repository_storage).to eq('test_second_storage')
+ expect(project.project_repository.shard_name).to eq('test_second_storage')
+ end
+ end
+
context 'with wiki repository' do
include_examples 'moves repository to another storage', 'wiki' do
let(:project) { create(:project, :repository, wiki_enabled: true) }
diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb
index 989426fde8b..760cd85bf71 100644
--- a/spec/services/projects/update_service_spec.rb
+++ b/spec/services/projects/update_service_spec.rb
@@ -127,11 +127,22 @@ RSpec.describe Projects::UpdateService do
end
context 'when updated by an admin' do
- it 'updates the project to public' do
- result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'updates the project to public' do
+ result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
- expect(result).to eq({ status: :success })
- expect(project).to be_public
+ expect(result).to eq({ status: :success })
+ expect(project).to be_public
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'does not update the project to public' do
+ result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
+
+ expect(result).to eq({ status: :error, message: 'New visibility level not allowed!' })
+ expect(project).to be_private
+ end
end
end
end
@@ -144,7 +155,7 @@ RSpec.describe Projects::UpdateService do
project.update!(namespace: group, visibility_level: group.visibility_level)
end
- it 'does not update project visibility level' do
+ it 'does not update project visibility level even if admin', :enable_admin_mode do
result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
expect(result).to eq({ status: :error, message: 'Visibility level public is not allowed in a internal group.' })
@@ -181,6 +192,7 @@ RSpec.describe Projects::UpdateService do
describe 'when updating project that has forks' do
let(:project) { create(:project, :internal) }
+ let(:user) { project.owner }
let(:forked_project) { fork_project(project) }
context 'and unlink forks feature flag is off' do
@@ -194,7 +206,7 @@ RSpec.describe Projects::UpdateService do
expect(project).to be_internal
expect(forked_project).to be_internal
- expect(update_project(project, admin, opts)).to eq({ status: :success })
+ expect(update_project(project, user, opts)).to eq({ status: :success })
expect(project).to be_private
expect(forked_project.reload).to be_private
@@ -206,7 +218,7 @@ RSpec.describe Projects::UpdateService do
expect(project).to be_internal
expect(forked_project).to be_internal
- expect(update_project(project, admin, opts)).to eq({ status: :success })
+ expect(update_project(project, user, opts)).to eq({ status: :success })
expect(project).to be_public
expect(forked_project.reload).to be_internal
@@ -220,7 +232,7 @@ RSpec.describe Projects::UpdateService do
expect(project).to be_internal
expect(forked_project).to be_internal
- expect(update_project(project, admin, opts)).to eq({ status: :success })
+ expect(update_project(project, user, opts)).to eq({ status: :success })
expect(project).to be_private
expect(forked_project.reload).to be_internal
@@ -576,15 +588,21 @@ RSpec.describe Projects::UpdateService do
context 'authenticated as admin' do
let(:user) { create(:admin) }
- it 'schedules the transfer of the repository to the new storage and locks the project' do
- update_project(project, admin, opts)
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'schedules the transfer of the repository to the new storage and locks the project' do
+ update_project(project, admin, opts)
- expect(project).to be_repository_read_only
- expect(project.repository_storage_moves.last).to have_attributes(
- state: ::ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value,
- source_storage_name: 'default',
- destination_storage_name: 'test_second_storage'
- )
+ expect(project).to be_repository_read_only
+ expect(project.repository_storage_moves.last).to have_attributes(
+ state: ::ProjectRepositoryStorageMove.state_machines[:state].states[:scheduled].value,
+ source_storage_name: 'default',
+ destination_storage_name: 'test_second_storage'
+ )
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it_behaves_like 'the transfer was not scheduled'
end
context 'the repository is read-only' do
diff --git a/spec/services/quick_actions/interpret_service_spec.rb b/spec/services/quick_actions/interpret_service_spec.rb
index 6f3814095f9..1f521ed4a93 100644
--- a/spec/services/quick_actions/interpret_service_spec.rb
+++ b/spec/services/quick_actions/interpret_service_spec.rb
@@ -312,8 +312,8 @@ RSpec.describe QuickActions::InterpretService do
end
end
- shared_examples 'wip command' do
- it 'returns wip_event: "wip" if content contains /wip' do
+ shared_examples 'draft command' do
+ it 'returns wip_event: "wip" if content contains /draft' do
_, updates, _ = service.execute(content, issuable)
expect(updates).to eq(wip_event: 'wip')
@@ -322,12 +322,12 @@ RSpec.describe QuickActions::InterpretService do
it 'returns the wip message' do
_, _, message = service.execute(content, issuable)
- expect(message).to eq("Marked this #{issuable.to_ability_name.humanize(capitalize: false)} as Work In Progress.")
+ expect(message).to eq("Marked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
end
end
- shared_examples 'unwip command' do
- it 'returns wip_event: "unwip" if content contains /wip' do
+ shared_examples 'undraft command' do
+ it 'returns wip_event: "unwip" if content contains /draft' do
issuable.update!(title: issuable.wip_title)
_, updates, _ = service.execute(content, issuable)
@@ -338,7 +338,7 @@ RSpec.describe QuickActions::InterpretService do
issuable.update!(title: issuable.wip_title)
_, _, message = service.execute(content, issuable)
- expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as Work In Progress.")
+ expect(message).to eq("Unmarked this #{issuable.to_ability_name.humanize(capitalize: false)} as a draft.")
end
end
@@ -1026,16 +1026,26 @@ RSpec.describe QuickActions::InterpretService do
let(:issuable) { issue }
end
- it_behaves_like 'wip command' do
+ it_behaves_like 'draft command' do
let(:content) { '/wip' }
let(:issuable) { merge_request }
end
- it_behaves_like 'unwip command' do
+ it_behaves_like 'undraft command' do
let(:content) { '/wip' }
let(:issuable) { merge_request }
end
+ it_behaves_like 'draft command' do
+ let(:content) { '/draft' }
+ let(:issuable) { merge_request }
+ end
+
+ it_behaves_like 'undraft command' do
+ let(:content) { '/draft' }
+ let(:issuable) { merge_request }
+ end
+
it_behaves_like 'empty command' do
let(:content) { '/remove_due_date' }
let(:issuable) { merge_request }
@@ -1896,13 +1906,13 @@ RSpec.describe QuickActions::InterpretService do
end
end
- describe 'wip command' do
- let(:content) { '/wip' }
+ describe 'draft command' do
+ let(:content) { '/draft' }
it 'includes the new status' do
_, explanations = service.explain(content, merge_request)
- expect(explanations).to eq(['Marks this merge request as Work In Progress.'])
+ expect(explanations).to eq(['Marks this merge request as a draft.'])
end
end
diff --git a/spec/services/releases/create_service_spec.rb b/spec/services/releases/create_service_spec.rb
index 90648340b66..b9294182883 100644
--- a/spec/services/releases/create_service_spec.rb
+++ b/spec/services/releases/create_service_spec.rb
@@ -22,6 +22,12 @@ RSpec.describe Releases::CreateService do
it 'creates a new release' do
expected_job_count = MailScheduler::NotificationServiceWorker.jobs.size + 1
+ expect_next_instance_of(Release) do |release|
+ expect(release)
+ .to receive(:execute_hooks)
+ .with('create')
+ end
+
result = service.execute
expect(project.releases.count).to eq(1)
diff --git a/spec/services/releases/update_service_spec.rb b/spec/services/releases/update_service_spec.rb
index 00544b820cb..932a7fab5ec 100644
--- a/spec/services/releases/update_service_spec.rb
+++ b/spec/services/releases/update_service_spec.rb
@@ -32,6 +32,12 @@ RSpec.describe Releases::UpdateService do
expect(result[:release].description).to eq(new_description)
end
+ it 'executes hooks' do
+ expect(service.release).to receive(:execute_hooks).with('update')
+
+ service.execute
+ end
+
context 'when the tag does not exists' do
let(:tag_name) { 'foobar' }
diff --git a/spec/services/reset_project_cache_service_spec.rb b/spec/services/reset_project_cache_service_spec.rb
index 3e79270da8d..165b38ee338 100644
--- a/spec/services/reset_project_cache_service_spec.rb
+++ b/spec/services/reset_project_cache_service_spec.rb
@@ -20,7 +20,7 @@ RSpec.describe ResetProjectCacheService do
context 'when project cache_index is a numeric value' do
before do
- project.update(jobs_cache_index: 1)
+ project.update!(jobs_cache_index: 1)
end
it 'increments project cache index' do
diff --git a/spec/services/resource_access_tokens/create_service_spec.rb b/spec/services/resource_access_tokens/create_service_spec.rb
index d8b12cda632..5cfa1ae93e6 100644
--- a/spec/services/resource_access_tokens/create_service_spec.rb
+++ b/spec/services/resource_access_tokens/create_service_spec.rb
@@ -11,16 +11,15 @@ RSpec.describe ResourceAccessTokens::CreateService do
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
+ shared_examples 'token creation fails' do
+ let(:resource) { create(:project)}
- it 'returns error' do
- response = subject
+ it 'does not add the project bot as a member' do
+ expect { subject }.not_to change { resource.members.count }
+ end
- expect(response.error?).to be true
- expect(response.message).to eq("User does not have permission to create #{resource_type} Access Token")
+ it 'immediately destroys the bot user if one was created', :sidekiq_inline do
+ expect { subject }.not_to change { User.bots.count }
end
end
@@ -47,8 +46,18 @@ RSpec.describe ResourceAccessTokens::CreateService do
end
context 'when created by an admin' do
- it_behaves_like 'creates a user that has their email confirmed' do
- let(:user) { create(:admin) }
+ let(:user) { create(:admin) }
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it_behaves_like 'creates a user that has their email confirmed'
+ end
+
+ context 'when admin mode is disabled' do
+ it 'returns error' do
+ response = subject
+
+ expect(response.error?).to be true
+ end
end
end
@@ -154,24 +163,36 @@ RSpec.describe ResourceAccessTokens::CreateService do
context 'when invalid scope is passed' do
let_it_be(:params) { { scopes: [:invalid_scope] } }
- it 'returns error' do
+ it_behaves_like 'token creation fails'
+
+ it 'returns the scope error message' do
response = subject
expect(response.error?).to be true
+ expect(response.errors).to include("Scopes can only contain available scopes")
end
end
end
- end
- context 'when access provisioning fails' do
- before do
- allow(resource).to receive(:add_user).and_return(nil)
- end
+ context "when access provisioning fails" do
+ let_it_be(:bot_user) { create(:user, :project_bot) }
+ let(:unpersisted_member) { build(:project_member, source: resource, user: bot_user) }
- it 'returns error' do
- response = subject
+ before do
+ allow_next_instance_of(ResourceAccessTokens::CreateService) do |service|
+ allow(service).to receive(:create_user).and_return(bot_user)
+ allow(service).to receive(:create_membership).and_return(unpersisted_member)
+ end
+ end
- expect(response.error?).to be true
+ it_behaves_like 'token creation fails'
+
+ it 'returns the provisioning error message' do
+ response = subject
+
+ expect(response.error?).to be true
+ expect(response.errors).to include("Could not provision maintainer access to project access token")
+ end
end
end
end
@@ -180,7 +201,16 @@ RSpec.describe ResourceAccessTokens::CreateService 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'
+ context 'when user does not have permission to create a resource bot' do
+ it_behaves_like 'token creation fails'
+
+ it 'returns the permission error message' do
+ response = subject
+
+ expect(response.error?).to be true
+ expect(response.errors).to include("User does not have permission to create #{resource_type} Access Token")
+ end
+ end
context 'user with valid permission' do
before_all do
diff --git a/spec/services/resource_events/change_milestone_service_spec.rb b/spec/services/resource_events/change_milestone_service_spec.rb
index 3a9dadbd40e..a2131c5c1b0 100644
--- a/spec/services/resource_events/change_milestone_service_spec.rb
+++ b/spec/services/resource_events/change_milestone_service_spec.rb
@@ -11,7 +11,7 @@ RSpec.describe ResourceEvents::ChangeMilestoneService do
[:issue, :merge_request].each do |issuable|
it_behaves_like 'timebox(milestone or iteration) resource events creator', ResourceMilestoneEvent do
- let_it_be(:resource) { create(issuable) }
+ let_it_be(:resource) { create(issuable) } # rubocop:disable Rails/SaveBang
end
end
end
diff --git a/spec/services/search/snippet_service_spec.rb b/spec/services/search/snippet_service_spec.rb
index ceaf3d055bf..d204f626635 100644
--- a/spec/services/search/snippet_service_spec.rb
+++ b/spec/services/search/snippet_service_spec.rb
@@ -49,12 +49,24 @@ RSpec.describe Search::SnippetService do
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: 'bar')
- results = search.execute
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'returns all snippets when user is admin' do
+ admin = create(:admin)
+ search = described_class.new(admin, search: 'bar')
+ results = search.execute
+
+ 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
+
+ context 'when admin mode is disabled' do
+ it 'returns only public & internal snippets when user is admin' do
+ admin = create(:admin)
+ search = described_class.new(admin, search: 'bar')
+ results = search.execute
- expect(results.objects('snippet_titles')).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, project_public_snippet, project_internal_snippet]
+ end
end
end
end
diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb
index fc613a6224a..40fb257b23e 100644
--- a/spec/services/search_service_spec.rb
+++ b/spec/services/search_service_spec.rb
@@ -18,9 +18,10 @@ RSpec.describe SearchService do
let(:group_project) { create(:project, group: accessible_group, name: 'group_project') }
let(:public_project) { create(:project, :public, name: 'public_project') }
+ let(: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) }
+ subject(:search_service) { described_class.new(user, search: search, scope: scope, page: page, per_page: per_page) }
before do
accessible_project.add_maintainer(user)
@@ -242,10 +243,10 @@ RSpec.describe SearchService do
end
describe '#search_objects' do
- context 'handling per_page param' do
- let(:search) { '' }
- let(:scope) { nil }
+ let(:search) { '' }
+ let(:scope) { nil }
+ describe 'per_page: parameter' do
context 'when nil' do
let(:per_page) { nil }
@@ -312,6 +313,34 @@ RSpec.describe SearchService do
end
end
+ describe 'page: parameter' do
+ context 'when < 1' do
+ let(:page) { 0 }
+
+ it "defaults to 1" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(page: 1))
+ .and_call_original
+
+ subject.search_objects
+ end
+ end
+
+ context 'when nil' do
+ let(:page) { nil }
+
+ it "defaults to 1" do
+ expect_any_instance_of(Gitlab::SearchResults)
+ .to receive(:objects)
+ .with(anything, hash_including(page: 1))
+ .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(
diff --git a/spec/services/snippets/create_service_spec.rb b/spec/services/snippets/create_service_spec.rb
index b7fb5a98d06..96807fd629f 100644
--- a/spec/services/snippets/create_service_spec.rb
+++ b/spec/services/snippets/create_service_spec.rb
@@ -147,9 +147,11 @@ RSpec.describe Snippets::CreateService do
end
context 'when the commit action fails' do
+ let(:error) { SnippetRepository::CommitError.new('foobar') }
+
before do
allow_next_instance_of(SnippetRepository) do |instance|
- allow(instance).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError.new('foobar'))
+ allow(instance).to receive(:multi_files_action).and_raise(error)
end
end
@@ -172,7 +174,7 @@ RSpec.describe Snippets::CreateService do
end
it 'logs the error' do
- expect(Gitlab::AppLogger).to receive(:error).with('foobar')
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(error, service: 'Snippets::CreateService')
subject
end
diff --git a/spec/services/snippets/update_service_spec.rb b/spec/services/snippets/update_service_spec.rb
index 406ece30bd7..a2341dc71b2 100644
--- a/spec/services/snippets/update_service_spec.rb
+++ b/spec/services/snippets/update_service_spec.rb
@@ -277,14 +277,14 @@ RSpec.describe Snippets::UpdateService do
end
context 'when an error is raised' do
- let(:error_message) { 'foobar' }
+ let(:error) { SnippetRepository::CommitError.new('foobar') }
before do
- allow(snippet.snippet_repository).to receive(:multi_files_action).and_raise(SnippetRepository::CommitError, error_message)
+ allow(snippet.snippet_repository).to receive(:multi_files_action).and_raise(error)
end
it 'logs the error' do
- expect(Gitlab::AppLogger).to receive(:error).with(error_message)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(error, service: 'Snippets::UpdateService')
subject
end
diff --git a/spec/services/system_hooks_service_spec.rb b/spec/services/system_hooks_service_spec.rb
index bdc40a92e91..b25837ca260 100644
--- a/spec/services/system_hooks_service_spec.rb
+++ b/spec/services/system_hooks_service_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe SystemHooksService do
end
it 'handles nil datetime columns' do
- user.update(created_at: nil, updated_at: nil)
+ user.update!(created_at: nil, updated_at: nil)
data = event_data(user, :destroy)
expect(data[:created_at]).to be(nil)
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index 42e48b9ad81..a4ae7e42958 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -378,13 +378,13 @@ RSpec.describe SystemNoteService do
noteable_types.each do |type|
context "when noteable is a #{type}" do
it "blocks cross reference when #{type.underscore}_events is false" do
- jira_tracker.update("#{type}_events" => false)
+ jira_tracker.update!("#{type}_events" => false)
expect(cross_reference(type)).to eq(s_('JiraService|Events for %{noteable_model_name} are disabled.') % { noteable_model_name: type.pluralize.humanize.downcase })
end
it "creates cross reference when #{type.underscore}_events is true" do
- jira_tracker.update("#{type}_events" => true)
+ jira_tracker.update!("#{type}_events" => true)
expect(cross_reference(type)).to eq(success_message)
end
@@ -566,25 +566,25 @@ RSpec.describe SystemNoteService do
end
end
- describe '.handle_merge_request_wip' do
+ describe '.handle_merge_request_draft' do
it 'calls MergeRequestsService' do
expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service|
- expect(service).to receive(:handle_merge_request_wip)
+ expect(service).to receive(:handle_merge_request_draft)
end
- described_class.handle_merge_request_wip(noteable, project, author)
+ described_class.handle_merge_request_draft(noteable, project, author)
end
end
- describe '.add_merge_request_wip_from_commit' do
+ describe '.add_merge_request_draft_from_commit' do
it 'calls MergeRequestsService' do
commit = double
expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service|
- expect(service).to receive(:add_merge_request_wip_from_commit).with(commit)
+ expect(service).to receive(:add_merge_request_draft_from_commit).with(commit)
end
- described_class.add_merge_request_wip_from_commit(noteable, project, author, commit)
+ described_class.add_merge_request_draft_from_commit(noteable, project, author, commit)
end
end
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index e78b00fb67a..b70c5e899fc 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -373,7 +373,7 @@ RSpec.describe ::SystemNotes::IssuablesService do
before do
# Mention issue (noteable) from commit0
system_note = service.cross_reference(commit0)
- system_note.update(note: system_note.note.capitalize)
+ system_note.update!(note: system_note.note.capitalize)
end
it 'is truthy when already mentioned' do
@@ -407,7 +407,7 @@ RSpec.describe ::SystemNotes::IssuablesService do
before do
# Mention commit1 from commit0
system_note = service.cross_reference(commit1)
- system_note.update(note: system_note.note.capitalize)
+ system_note.update!(note: system_note.note.capitalize)
end
it 'is truthy when already mentioned' do
@@ -436,7 +436,7 @@ RSpec.describe ::SystemNotes::IssuablesService do
context 'legacy capitalized cross reference' do
before do
system_note = service.cross_reference(commit0)
- system_note.update(note: system_note.note.capitalize)
+ system_note.update!(note: system_note.note.capitalize)
end
it 'is true when a fork mentions an external issue' do
@@ -582,7 +582,7 @@ RSpec.describe ::SystemNotes::IssuablesService do
it 'creates the note text correctly' do
[:issue, :merge_request].each do |type|
- issuable = create(type)
+ issuable = create(type) # rubocop:disable Rails/SaveBang
service = described_class.new(noteable: issuable, author: author)
expect(service.discussion_lock.note)
diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb
index 067e1cef64d..50d16231e8f 100644
--- a/spec/services/system_notes/merge_requests_service_spec.rb
+++ b/spec/services/system_notes/merge_requests_service_spec.rb
@@ -51,44 +51,44 @@ RSpec.describe ::SystemNotes::MergeRequestsService do
end
end
- describe '.handle_merge_request_wip' do
+ describe '.handle_merge_request_draft' do
context 'adding draft note' do
let(:noteable) { create(:merge_request, source_project: project, title: 'Draft: Lorem ipsum') }
- subject { service.handle_merge_request_wip }
+ subject { service.handle_merge_request_draft }
it_behaves_like 'a system note' do
let(:action) { 'title' }
end
it 'sets the note text' do
- expect(subject.note).to eq 'marked as a **Work In Progress**'
+ expect(subject.note).to eq 'marked this merge request as **draft**'
end
end
- context 'removing wip note' do
- subject { service.handle_merge_request_wip }
+ context 'removing draft note' do
+ subject { service.handle_merge_request_draft }
it_behaves_like 'a system note' do
let(:action) { 'title' }
end
it 'sets the note text' do
- expect(subject.note).to eq 'unmarked as a **Work In Progress**'
+ expect(subject.note).to eq 'marked this merge request as **ready**'
end
end
end
- describe '.add_merge_request_wip_from_commit' do
- subject { service.add_merge_request_wip_from_commit(noteable.diff_head_commit) }
+ describe '.add_merge_request_draft_from_commit' do
+ subject { service.add_merge_request_draft_from_commit(noteable.diff_head_commit) }
it_behaves_like 'a system note' do
let(:action) { 'title' }
end
- it "posts the 'marked as a Work In Progress from commit' system note" do
+ it "posts the 'marked this merge request as draft from commit' system note" do
expect(subject.note).to match(
- /marked as a \*\*Work In Progress\*\* from #{Commit.reference_pattern}/
+ /marked this merge request as \*\*draft\*\* from #{Commit.reference_pattern}/
)
end
end
diff --git a/spec/services/test_hooks/project_service_spec.rb b/spec/services/test_hooks/project_service_spec.rb
index e4cc3a2d652..7470bdff527 100644
--- a/spec/services/test_hooks/project_service_spec.rb
+++ b/spec/services/test_hooks/project_service_spec.rb
@@ -186,5 +186,23 @@ RSpec.describe TestHooks::ProjectService do
expect(service.execute).to include(success_result)
end
end
+
+ context 'releases_events' do
+ let(:trigger) { 'releases_events' }
+ let(:trigger_key) { :release_hooks }
+
+ it 'returns error message if not enough data' do
+ expect(hook).not_to receive(:execute)
+ expect(service.execute).to include({ status: :error, message: 'Ensure the project has releases.' })
+ end
+
+ it 'executes hook' do
+ allow(project).to receive(:releases).and_return([Release.new])
+ allow_any_instance_of(Release).to receive(:to_hook_data).and_return(sample_data)
+
+ expect(hook).to receive(:execute).with(sample_data, trigger_key).and_return(success_result)
+ expect(service.execute).to include(success_result)
+ end
+ end
end
end
diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb
index 60903f8f367..90325c564bc 100644
--- a/spec/services/todo_service_spec.rb
+++ b/spec/services/todo_service_spec.rb
@@ -145,12 +145,12 @@ RSpec.describe TodoService do
end
it 'creates correct todos for each valid user based on the type of mention' do
- issue.update(description: directly_addressed_and_mentioned)
+ issue.update!(description: directly_addressed_and_mentioned)
service.new_issue(issue, author)
should_create_todo(user: member, target: issue, action: Todo::DIRECTLY_ADDRESSED)
- should_create_todo(user: admin, target: issue, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: issue, action: Todo::MENTIONED)
should_create_todo(user: guest, target: issue, action: Todo::MENTIONED)
end
@@ -160,7 +160,7 @@ RSpec.describe TodoService do
should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::ASSIGNED)
should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
- should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
end
@@ -171,7 +171,7 @@ RSpec.describe TodoService do
should_create_todo(user: assignee, target: addressed_confident_issue, author: john_doe, action: Todo::ASSIGNED)
should_create_todo(user: author, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: member, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
- should_create_todo(user: admin, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
+ should_not_create_todo(user: admin, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_not_create_todo(user: guest, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: john_doe, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
end
@@ -222,13 +222,13 @@ RSpec.describe TodoService do
end
it 'creates a todo for each valid user not included in skip_users based on the type of mention' do
- issue.update(description: directly_addressed_and_mentioned)
+ issue.update!(description: directly_addressed_and_mentioned)
service.update_issue(issue, author, skip_users)
should_create_todo(user: member, target: issue, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: guest, target: issue, action: Todo::MENTIONED)
- should_create_todo(user: admin, target: issue, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: issue, action: Todo::MENTIONED)
should_not_create_todo(user: skipped, target: issue)
end
@@ -273,7 +273,7 @@ RSpec.describe TodoService do
should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
- should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
should_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED)
end
@@ -284,14 +284,14 @@ RSpec.describe TodoService do
should_create_todo(user: author, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: assignee, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: member, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
- should_create_todo(user: admin, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
+ should_not_create_todo(user: admin, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_not_create_todo(user: guest, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: john_doe, target: addressed_confident_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED)
end
context 'issues with a task list' do
it 'does not create todo when tasks are marked as completed' do
- issue.update(description: "- [x] Task 1\n- [X] Task 2 #{mentions}")
+ issue.update!(description: "- [x] Task 1\n- [X] Task 2 #{mentions}")
service.update_issue(issue, author)
@@ -304,7 +304,7 @@ RSpec.describe TodoService do
end
it 'does not create directly addressed todo when tasks are marked as completed' do
- addressed_issue.update(description: "#{directly_addressed}\n- [x] Task 1\n- [x] Task 2\n")
+ addressed_issue.update!(description: "#{directly_addressed}\n- [x] Task 1\n- [x] Task 2\n")
service.update_issue(addressed_issue, author)
@@ -317,7 +317,7 @@ RSpec.describe TodoService do
end
it 'does not raise an error when description not change' do
- issue.update(title: 'Sample')
+ issue.update!(title: 'Sample')
expect { service.update_issue(issue, author) }.not_to raise_error
end
@@ -427,12 +427,12 @@ RSpec.describe TodoService do
end
it 'creates a todo for each valid user based on the type of mention' do
- note.update(note: directly_addressed_and_mentioned)
+ note.update!(note: directly_addressed_and_mentioned)
service.new_note(note, john_doe)
should_create_todo(user: member, target: issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: note)
- should_create_todo(user: admin, target: issue, author: john_doe, action: Todo::MENTIONED, note: note)
+ should_not_create_todo(user: admin, target: issue, author: john_doe, action: Todo::MENTIONED, note: note)
should_create_todo(user: guest, target: issue, author: john_doe, action: Todo::MENTIONED, note: note)
end
@@ -452,7 +452,7 @@ RSpec.describe TodoService do
should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
- should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
+ should_not_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
should_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue)
end
@@ -463,7 +463,7 @@ RSpec.describe TodoService do
should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
- should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
+ should_not_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
should_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::DIRECTLY_ADDRESSED, note: addressed_note_on_confidential_issue)
end
@@ -694,12 +694,12 @@ RSpec.describe TodoService do
end
it 'creates a todo for each valid user based on the type of mention' do
- mr_assigned.update(description: directly_addressed_and_mentioned)
+ mr_assigned.update!(description: directly_addressed_and_mentioned)
service.new_merge_request(mr_assigned, author)
should_create_todo(user: member, target: mr_assigned, action: Todo::DIRECTLY_ADDRESSED)
- should_create_todo(user: admin, target: mr_assigned, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: mr_assigned, action: Todo::MENTIONED)
end
it 'creates a directly addressed todo for each valid addressed user' do
@@ -726,12 +726,12 @@ RSpec.describe TodoService do
end
it 'creates a todo for each valid user not included in skip_users based on the type of mention' do
- mr_assigned.update(description: directly_addressed_and_mentioned)
+ mr_assigned.update!(description: directly_addressed_and_mentioned)
service.update_merge_request(mr_assigned, author, skip_users)
should_create_todo(user: member, target: mr_assigned, action: Todo::DIRECTLY_ADDRESSED)
- should_create_todo(user: admin, target: mr_assigned, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: mr_assigned, action: Todo::MENTIONED)
should_not_create_todo(user: skipped, target: mr_assigned)
end
@@ -772,7 +772,7 @@ RSpec.describe TodoService do
context 'with a task list' do
it 'does not create todo when tasks are marked as completed' do
- mr_assigned.update(description: "- [x] Task 1\n- [X] Task 2 #{mentions}")
+ mr_assigned.update!(description: "- [x] Task 1\n- [X] Task 2 #{mentions}")
service.update_merge_request(mr_assigned, author)
@@ -786,7 +786,7 @@ RSpec.describe TodoService do
end
it 'does not create directly addressed todo when tasks are marked as completed' do
- addressed_mr_assigned.update(description: "#{directly_addressed}\n- [x] Task 1\n- [X] Task 2")
+ addressed_mr_assigned.update!(description: "#{directly_addressed}\n- [x] Task 1\n- [X] Task 2")
service.update_merge_request(addressed_mr_assigned, author)
@@ -800,7 +800,7 @@ RSpec.describe TodoService do
end
it 'does not raise an error when description not change' do
- mr_assigned.update(title: 'Sample')
+ mr_assigned.update!(title: 'Sample')
expect { service.update_merge_request(mr_assigned, author) }.not_to raise_error
end
@@ -883,7 +883,7 @@ RSpec.describe TodoService do
end
it 'creates a pending todo for each merge_participant' do
- mr_unassigned.update(merge_when_pipeline_succeeds: true, merge_user: admin)
+ mr_unassigned.update!(merge_when_pipeline_succeeds: true, merge_user: admin)
service.merge_request_became_unmergeable(mr_unassigned)
merge_participants.each do |participant|
@@ -991,13 +991,13 @@ RSpec.describe TodoService do
end
it 'creates a todo for each valid user not included in skip_users based on the type of mention' do
- note.update(note: directly_addressed_and_mentioned)
+ note.update!(note: directly_addressed_and_mentioned)
service.update_note(note, author, skip_users)
should_create_todo(user: member, target: noteable, action: Todo::DIRECTLY_ADDRESSED)
should_create_todo(user: guest, target: noteable, action: Todo::MENTIONED)
- should_create_todo(user: admin, target: noteable, action: Todo::MENTIONED)
+ should_not_create_todo(user: admin, target: noteable, action: Todo::MENTIONED)
should_not_create_todo(user: skipped, target: noteable)
end
diff --git a/spec/services/todos/destroy/confidential_issue_service_spec.rb b/spec/services/todos/destroy/confidential_issue_service_spec.rb
index ddce45e7968..e3dcc2bae95 100644
--- a/spec/services/todos/destroy/confidential_issue_service_spec.rb
+++ b/spec/services/todos/destroy/confidential_issue_service_spec.rb
@@ -37,7 +37,7 @@ RSpec.describe Todos::Destroy::ConfidentialIssueService do
context 'when provided issue is not confidential' do
it 'does not remove any todos' do
- issue_1.update(confidential: false)
+ issue_1.update!(confidential: false)
expect { subject }.not_to change { Todo.count }
end
diff --git a/spec/services/two_factor/destroy_service_spec.rb b/spec/services/two_factor/destroy_service_spec.rb
index 3df4d1593c6..30c189520fd 100644
--- a/spec/services/two_factor/destroy_service_spec.rb
+++ b/spec/services/two_factor/destroy_service_spec.rb
@@ -85,7 +85,7 @@ RSpec.describe TwoFactor::DestroyService do
it_behaves_like 'disables two-factor authentication'
end
- context 'admin disables the two-factor authentication of another user' do
+ context 'admin disables the two-factor authentication of another user', :enable_admin_mode do
let(:current_user) { create(:admin) }
let(:user) { create(:user, :two_factor) }
diff --git a/spec/services/users/approve_service_spec.rb b/spec/services/users/approve_service_spec.rb
index 50f2b6b0827..55b2c83f4a8 100644
--- a/spec/services/users/approve_service_spec.rb
+++ b/spec/services/users/approve_service_spec.rb
@@ -19,85 +19,101 @@ RSpec.describe Users::ApproveService do
end
end
- context 'when user is not in pending approval state' do
- let(:user) { create(:user, state: 'active') }
-
+ context 'when the executor user is an admin not in admin mode' do
it 'returns error result' do
expect(subject[:status]).to eq(:error)
- expect(subject[:message])
- .to match(/The user you are trying to approve is not pending an approval/)
+ expect(subject[:message]).to match(/You are not allowed to approve a user/)
end
end
- context 'when user cannot be activated' do
- let(:user) do
- build(:user, state: 'blocked_pending_approval', email: 'invalid email')
- end
+ context 'when the executor user is an admin in admin mode', :enable_admin_mode do
+ context 'when user is not in pending approval state' do
+ let(:user) { create(:user, state: 'active') }
- it 'returns error result' do
- expect(subject[:status]).to eq(:error)
- expect(subject[:message]).to match(/Email is invalid/)
+ it 'returns error result' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message])
+ .to match(/The user you are trying to approve is not pending an approval/)
+ end
end
- it 'does not change the state of the user' do
- expect { subject }.not_to change { user.state }
+ context 'when user cannot be activated' do
+ let(:user) do
+ build(:user, state: 'blocked_pending_approval', email: 'invalid email')
+ end
+
+ it 'returns error result' do
+ expect(subject[:status]).to eq(:error)
+ expect(subject[:message]).to match(/Email is invalid/)
+ end
+
+ it 'does not change the state of the user' do
+ expect { subject }.not_to change { user.state }
+ end
end
end
end
context 'success' do
- it 'activates the user' do
- expect(subject[:status]).to eq(:success)
- expect(user.reload).to be_active
- end
+ context 'when the executor user is an admin in admin mode', :enable_admin_mode do
+ it 'activates the user' do
+ expect(subject[:status]).to eq(:success)
+ expect(user.reload).to be_active
+ end
- context 'email confirmation status' do
- context 'user is unconfirmed' do
- let(:user) { create(:user, :blocked_pending_approval, :unconfirmed) }
+ it 'emails the user on approval' do
+ expect(DeviseMailer).to receive(:user_admin_approval).with(user).and_call_original
+ expect { subject }.to have_enqueued_mail(DeviseMailer, :user_admin_approval)
+ end
- it 'sends confirmation instructions' do
- expect { subject }
- .to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ context 'email confirmation status' do
+ context 'user is unconfirmed' do
+ let(:user) { create(:user, :blocked_pending_approval, :unconfirmed) }
+
+ it 'sends confirmation instructions' do
+ expect { subject }
+ .to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ end
end
- end
- context 'user is confirmed' do
- it 'does not send a confirmation email' do
- expect { subject }
- .not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ context 'user is confirmed' do
+ it 'does not send a confirmation email' do
+ expect { subject }
+ .not_to have_enqueued_mail(DeviseMailer, :confirmation_instructions)
+ end
end
end
- end
- context 'pending invitiations' do
- let!(:project_member_invite) { create(:project_member, :invited, invite_email: user.email) }
- let!(:group_member_invite) { create(:group_member, :invited, invite_email: user.email) }
+ context 'pending invitations' do
+ let!(:project_member_invite) { create(:project_member, :invited, invite_email: user.email) }
+ let!(:group_member_invite) { create(:group_member, :invited, invite_email: user.email) }
- context 'user is unconfirmed' do
- let(:user) { create(:user, :blocked_pending_approval, :unconfirmed) }
+ context 'user is unconfirmed' do
+ let(:user) { create(:user, :blocked_pending_approval, :unconfirmed) }
- it 'does not accept pending invites of the user' do
- expect(subject[:status]).to eq(:success)
+ it 'does not accept pending invites of the user' do
+ expect(subject[:status]).to eq(:success)
- group_member_invite.reload
- project_member_invite.reload
+ group_member_invite.reload
+ project_member_invite.reload
- expect(group_member_invite).to be_invite
- expect(project_member_invite).to be_invite
+ expect(group_member_invite).to be_invite
+ expect(project_member_invite).to be_invite
+ end
end
- end
- context 'user is confirmed' do
- it 'accepts pending invites of the user' do
- expect(subject[:status]).to eq(:success)
+ context 'user is confirmed' do
+ it 'accepts pending invites of the user' do
+ expect(subject[:status]).to eq(:success)
- group_member_invite.reload
- project_member_invite.reload
+ group_member_invite.reload
+ project_member_invite.reload
- expect(group_member_invite).not_to be_invite
- expect(project_member_invite).not_to be_invite
- expect(group_member_invite.user).to eq(user)
- expect(project_member_invite.user).to eq(user)
+ expect(group_member_invite).not_to be_invite
+ expect(project_member_invite).not_to be_invite
+ expect(group_member_invite.user).to eq(user)
+ expect(project_member_invite.user).to eq(user)
+ end
end
end
end
diff --git a/spec/services/users/destroy_service_spec.rb b/spec/services/users/destroy_service_spec.rb
index 6de685dd89a..76b84e3b4ab 100644
--- a/spec/services/users/destroy_service_spec.rb
+++ b/spec/services/users/destroy_service_spec.rb
@@ -3,14 +3,14 @@
require 'spec_helper'
RSpec.describe Users::DestroyService do
- describe "Deletes a user and all their personal projects" do
- let!(:user) { create(:user) }
- let!(:admin) { create(:admin) }
- let!(:namespace) { user.namespace }
- let!(:project) { create(:project, namespace: namespace) }
- let(:service) { described_class.new(admin) }
- let(:gitlab_shell) { Gitlab::Shell.new }
-
+ let!(:user) { create(:user) }
+ let!(:admin) { create(:admin) }
+ let!(:namespace) { user.namespace }
+ let!(:project) { create(:project, namespace: namespace) }
+ let(:service) { described_class.new(admin) }
+ let(:gitlab_shell) { Gitlab::Shell.new }
+
+ describe "Deletes a user and all their personal projects", :enable_admin_mode do
context 'no options are given' do
it 'deletes the user' do
user_data = service.execute(user)
@@ -108,7 +108,7 @@ RSpec.describe Users::DestroyService do
context 'projects in pending_delete' do
before do
project.pending_delete = true
- project.save
+ project.save!
end
it 'destroys a project in pending_delete' do
@@ -215,35 +215,6 @@ RSpec.describe Users::DestroyService do
end
end
- context "deletion permission checks" do
- it 'does not delete the user when user is not an admin' do
- other_user = create(:user)
-
- expect { described_class.new(other_user).execute(user) }.to raise_error(Gitlab::Access::AccessDeniedError)
- expect(User.exists?(user.id)).to be(true)
- end
-
- it 'allows admins to delete anyone' do
- described_class.new(admin).execute(user)
-
- expect(User.exists?(user.id)).to be(false)
- end
-
- it 'allows users to delete their own account' do
- described_class.new(user).execute(user)
-
- expect(User.exists?(user.id)).to be(false)
- end
-
- it 'allows user to be deleted if skip_authorization: true' do
- other_user = create(:user)
-
- described_class.new(user).execute(other_user, skip_authorization: true)
-
- expect(User.exists?(other_user.id)).to be(false)
- end
- end
-
context "migrating associated records" do
let!(:issue) { create(:issue, author: user) }
@@ -310,7 +281,7 @@ RSpec.describe Users::DestroyService do
it 'of group_members' do
group_member = create(:group_member)
- group_member.group.group_members.create(user: user, access_level: 40)
+ group_member.group.group_members.create!(user: user, access_level: 40)
expect_any_instance_of(GroupMember).to receive(:run_callbacks).with(:find).once
expect_any_instance_of(GroupMember).to receive(:run_callbacks).with(:initialize).once
@@ -320,4 +291,43 @@ RSpec.describe Users::DestroyService do
end
end
end
+
+ describe "Deletion permission checks" do
+ it 'does not delete the user when user is not an admin' do
+ other_user = create(:user)
+
+ expect { described_class.new(other_user).execute(user) }.to raise_error(Gitlab::Access::AccessDeniedError)
+ expect(User.exists?(user.id)).to be(true)
+ end
+
+ context 'when admin mode is enabled', :enable_admin_mode do
+ it 'allows admins to delete anyone' do
+ described_class.new(admin).execute(user)
+
+ expect(User.exists?(user.id)).to be(false)
+ end
+ end
+
+ context 'when admin mode is disabled' do
+ it 'disallows admins to delete anyone' do
+ expect { described_class.new(admin).execute(user) }.to raise_error(Gitlab::Access::AccessDeniedError)
+
+ expect(User.exists?(user.id)).to be(true)
+ end
+ end
+
+ it 'allows users to delete their own account' do
+ described_class.new(user).execute(user)
+
+ expect(User.exists?(user.id)).to be(false)
+ end
+
+ it 'allows user to be deleted if skip_authorization: true' do
+ other_user = create(:user)
+
+ described_class.new(user).execute(other_user, skip_authorization: true)
+
+ expect(User.exists?(other_user.id)).to be(false)
+ end
+ end
end
diff --git a/spec/services/users/repair_ldap_blocked_service_spec.rb b/spec/services/users/repair_ldap_blocked_service_spec.rb
index b33dcb92f45..54540d68af2 100644
--- a/spec/services/users/repair_ldap_blocked_service_spec.rb
+++ b/spec/services/users/repair_ldap_blocked_service_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Users::RepairLdapBlockedService do
describe '#execute' do
it 'changes to normal block after destroying last ldap identity' do
- identity.destroy
+ identity.destroy!
service.execute
expect(user.reload).not_to be_ldap_blocked
diff --git a/spec/services/users/set_status_service_spec.rb b/spec/services/users/set_status_service_spec.rb
index 54489adceb0..69cd647eaeb 100644
--- a/spec/services/users/set_status_service_spec.rb
+++ b/spec/services/users/set_status_service_spec.rb
@@ -9,13 +9,14 @@ RSpec.describe Users::SetStatusService do
describe '#execute' do
context 'when params are set' do
- let(:params) { { emoji: 'taurus', message: 'a random status' } }
+ let(:params) { { emoji: 'taurus', message: 'a random status', availability: 'busy' } }
it 'creates a status' do
service.execute
expect(current_user.status.emoji).to eq('taurus')
expect(current_user.status.message).to eq('a random status')
+ expect(current_user.status.availability).to eq('busy')
end
it 'updates a status if it already existed' do
@@ -25,13 +26,33 @@ RSpec.describe Users::SetStatusService do
expect(current_user.status.message).to eq('a random status')
end
+ it 'returns true' do
+ create(:user_status, user: current_user)
+ expect(service.execute).to be(true)
+ end
+
+ context 'when the given availability value is not valid' do
+ let(:params) { { availability: 'not a valid value' } }
+
+ it 'does not update the status' do
+ user_status = create(:user_status, user: current_user)
+
+ expect { service.execute }.not_to change { user_status.reload }
+ end
+
+ it 'returns false' do
+ create(:user_status, user: current_user)
+ expect(service.execute).to be(false)
+ end
+ end
+
context 'for another user' do
let(:target_user) { create(:user) }
let(:params) do
{ emoji: 'taurus', message: 'a random status', user: target_user }
end
- context 'the current user is admin' do
+ context 'the current user is admin', :enable_admin_mode do
let(:current_user) { create(:admin) }
it 'changes the status when the current user is allowed to do that' do
diff --git a/spec/services/verify_pages_domain_service_spec.rb b/spec/services/verify_pages_domain_service_spec.rb
index 29ad85a16ce..ae079229891 100644
--- a/spec/services/verify_pages_domain_service_spec.rb
+++ b/spec/services/verify_pages_domain_service_spec.rb
@@ -189,7 +189,7 @@ RSpec.describe VerifyPagesDomainService do
let(:domain) { build(:pages_domain, :expired, :with_missing_chain) }
before do
- domain.save(validate: false)
+ domain.save!(validate: false)
end
it 'can be disabled' do
diff --git a/spec/services/web_hook_service_spec.rb b/spec/services/web_hook_service_spec.rb
index b7b81d33c3e..a607a6734b0 100644
--- a/spec/services/web_hook_service_spec.rb
+++ b/spec/services/web_hook_service_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe WebHookService do
let(:headers) do
{
'Content-Type' => 'application/json',
+ 'User-Agent' => "GitLab/#{Gitlab::VERSION}",
'X-Gitlab-Event' => 'Push Hook'
}
end
diff --git a/spec/sidekiq/cron/job_gem_dependency_spec.rb b/spec/sidekiq/cron/job_gem_dependency_spec.rb
index 2924c86c8d0..38c658feba6 100644
--- a/spec/sidekiq/cron/job_gem_dependency_spec.rb
+++ b/spec/sidekiq/cron/job_gem_dependency_spec.rb
@@ -7,7 +7,7 @@ RSpec.describe Sidekiq::Cron::Job do
context 'when Fugit depends on ZoTime or EoTime' do
before do
described_class
- .create(name: 'TestCronWorker',
+ .create(name: 'TestCronWorker', # rubocop:disable Rails/SaveBang
cron: Settings.cron_jobs[:pipeline_schedule_worker]['cron'],
class: Settings.cron_jobs[:pipeline_schedule_worker]['job_class'])
end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 11a45e005b8..38e3f851116 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,8 +1,19 @@
# frozen_string_literal: true
+# $" is $LOADED_FEATURES, but RuboCop didn't like it
+if $".include?(File.expand_path('fast_spec_helper.rb', __dir__))
+ warn 'Detected fast_spec_helper is loaded first than spec_helper.'
+ warn 'If running test files using both spec_helper and fast_spec_helper,'
+ warn 'make sure test file with spec_helper is loaded first.'
+ abort 'Aborting...'
+end
+
require './spec/simplecov_env'
SimpleCovEnv.start!
+require './spec/crystalball_env'
+CrystalballEnv.start!
+
ENV["RAILS_ENV"] = 'test'
ENV["IN_MEMORY_APPLICATION_SETTINGS"] = 'true'
ENV["RSPEC_ALLOW_INVALID_URLS"] = 'true'
@@ -45,6 +56,7 @@ require_relative('../ee/spec/spec_helper') if Gitlab.ee?
# Load these first since they may be required by other helpers
require Rails.root.join("spec/support/helpers/git_helpers.rb")
+require Rails.root.join("spec/support/helpers/stub_requests.rb")
# Then the rest
Dir[Rails.root.join("spec/support/helpers/*.rb")].sort.each { |f| require f }
@@ -119,7 +131,6 @@ RSpec.configure do |config|
config.include StubExperiments
config.include StubGitlabCalls
config.include StubGitlabData
- config.include SnowplowHelpers
config.include NextFoundInstanceOf
config.include NextInstanceOf
config.include TestEnv
@@ -273,12 +284,10 @@ RSpec.configure do |config|
./ee/spec/lib
./ee/spec/requests/admin
./ee/spec/serializers
- ./ee/spec/services
./ee/spec/support/protected_tags
./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
@@ -286,7 +295,6 @@ RSpec.configure do |config|
./spec/lib
./spec/requests
./spec/serializers
- ./spec/services
./spec/support/protected_tags
./spec/support/shared_examples/features
./spec/support/shared_examples/requests
@@ -358,7 +366,7 @@ RSpec.configure do |config|
end
config.before(:example, :prometheus) do
- matching_files = File.join(::Prometheus::Client.configuration.multiprocess_files_dir, "*.db")
+ matching_files = File.join(::Prometheus::Client.configuration.multiprocess_files_dir, "**/*.db")
Dir[matching_files].map { |filename| File.delete(filename) if File.file?(filename) }
Gitlab::Metrics.reset_registry!
diff --git a/spec/support/cycle_analytics_helpers/test_generation.rb b/spec/support/cycle_analytics_helpers/test_generation.rb
index c577e5cc665..f866220b919 100644
--- a/spec/support/cycle_analytics_helpers/test_generation.rb
+++ b/spec/support/cycle_analytics_helpers/test_generation.rb
@@ -6,7 +6,7 @@
# multiple nested contexts. This shouldn't count as a violation.
module CycleAnalyticsHelpers
module TestGeneration
- # Generate the most common set of specs that all cycle analytics phases need to have.
+ # Generate the most common set of specs that all value stream analytics phases need to have.
#
# Arguments:
#
@@ -14,10 +14,10 @@ module CycleAnalyticsHelpers
# data_fn: A function that returns a hash, constituting initial data for the test case
# start_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
# `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
- # Each `condition_fn` is expected to implement a case which consitutes the start of the given cycle analytics phase.
+ # Each `condition_fn` is expected to implement a case which consitutes the start of the given value stream analytics phase.
# end_time_conditions: An array of `conditions`. Each condition is an tuple of `condition_name` and `condition_fn`. `condition_fn` is called with
# `context` (no lexical scope, so need to do `context.create` for factories, for example) and `data` (from the `data_fn`).
- # Each `condition_fn` is expected to implement a case which consitutes the end of the given cycle analytics phase.
+ # Each `condition_fn` is expected to implement a case which consitutes the end of the given value stream analytics phase.
# before_end_fn: This function is run before calling the end time conditions. Used for setup that needs to be run between the start and end conditions.
# post_fn: Code that needs to be run after running the end time conditions.
diff --git a/spec/support/helpers/admin_mode_helpers.rb b/spec/support/helpers/admin_mode_helpers.rb
index 36ed262f8ae..a6e31791127 100644
--- a/spec/support/helpers/admin_mode_helpers.rb
+++ b/spec/support/helpers/admin_mode_helpers.rb
@@ -13,6 +13,8 @@ module AdminModeHelper
def enable_admin_mode!(user)
fake_user_mode = instance_double(Gitlab::Auth::CurrentUserMode)
+ allow(Gitlab::Auth::CurrentUserMode).to receive(:new).and_call_original
+
allow(Gitlab::Auth::CurrentUserMode).to receive(:new).with(user).and_return(fake_user_mode)
allow(fake_user_mode).to receive(:admin_mode?).and_return(user&.admin?)
end
diff --git a/spec/support/helpers/api_helpers.rb b/spec/support/helpers/api_helpers.rb
index b1e6078c4f2..d3cc7367b6e 100644
--- a/spec/support/helpers/api_helpers.rb
+++ b/spec/support/helpers/api_helpers.rb
@@ -61,7 +61,6 @@ module ApiHelpers
def expect_response_contain_exactly(*items)
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_an Array
- expect(json_response.length).to eq(items.size)
expect(json_response.map { |item| item['id'] }).to contain_exactly(*items)
end
diff --git a/spec/support/helpers/dependency_proxy_helpers.rb b/spec/support/helpers/dependency_proxy_helpers.rb
new file mode 100644
index 00000000000..545b9d1f4d0
--- /dev/null
+++ b/spec/support/helpers/dependency_proxy_helpers.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module DependencyProxyHelpers
+ include StubRequests
+
+ def stub_registry_auth(image, token, status = 200, body = nil)
+ auth_body = { 'token' => token }.to_json
+ auth_link = registry.auth_url(image)
+
+ stub_full_request(auth_link)
+ .to_return(status: status, body: body || auth_body)
+ end
+
+ def stub_manifest_download(image, tag, status = 200, body = nil)
+ manifest_url = registry.manifest_url(image, tag)
+
+ stub_full_request(manifest_url)
+ .to_return(status: status, body: body || manifest)
+ end
+
+ def stub_blob_download(image, blob_sha, status = 200, body = '123456')
+ download_link = registry.blob_url(image, blob_sha)
+
+ stub_full_request(download_link)
+ .to_return(status: status, body: body)
+ end
+
+ private
+
+ def registry
+ @registry ||= DependencyProxy::Registry
+ end
+end
diff --git a/spec/support/helpers/features/members_table_helpers.rb b/spec/support/helpers/features/members_table_helpers.rb
new file mode 100644
index 00000000000..5394e370900
--- /dev/null
+++ b/spec/support/helpers/features/members_table_helpers.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Spec
+ module Support
+ module Helpers
+ module Features
+ module MembersHelpers
+ def members_table
+ page.find('[data-testid="members-table"]')
+ end
+
+ def all_rows
+ page.within(members_table) do
+ page.all('tbody > tr')
+ end
+ end
+
+ def first_row
+ all_rows[0]
+ end
+
+ def second_row
+ all_rows[1]
+ end
+
+ def third_row
+ all_rows[2]
+ end
+
+ def invite_users_form
+ page.find('[data-testid="invite-users-form"]')
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb
index 0d46918b05c..44087f71cfa 100644
--- a/spec/support/helpers/features/releases_helpers.rb
+++ b/spec/support/helpers/features/releases_helpers.rb
@@ -66,7 +66,7 @@ module Spec
focused_element.send_keys(:enter)
# Wait for the dropdown to be rendered
- page.find('.project-milestone-combobox .dropdown-menu')
+ page.find('.milestone-combobox .dropdown-menu')
# Clear any existing input
focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) }
@@ -75,7 +75,7 @@ module Spec
focused_element.send_keys(milestone_title, :enter)
# Wait for the search to return
- page.find('.project-milestone-combobox .dropdown-item', text: milestone_title, match: :first)
+ page.find('.milestone-combobox .dropdown-item', text: milestone_title, match: :first)
focused_element.send_keys(:arrow_down, :arrow_down, :enter)
diff --git a/spec/support/helpers/features/web_ide_spec_helpers.rb b/spec/support/helpers/features/web_ide_spec_helpers.rb
index 123bd9b5070..12d3cecd052 100644
--- a/spec/support/helpers/features/web_ide_spec_helpers.rb
+++ b/spec/support/helpers/features/web_ide_spec_helpers.rb
@@ -22,6 +22,8 @@ module WebIdeSpecHelpers
click_link('Web IDE')
wait_for_requests
+
+ save_monaco_editor_reference
end
def ide_tree_body
@@ -36,8 +38,8 @@ module WebIdeSpecHelpers
".js-ide-#{mode}-mode"
end
- def ide_file_row_open?(row)
- row.matches_css?('.is-open')
+ def ide_folder_row_open?(row)
+ row.matches_css?('.folder.is-open')
end
# Creates a file in the IDE by expanding directories
@@ -63,6 +65,17 @@ module WebIdeSpecHelpers
ide_set_editor_value(content)
end
+ def ide_rename_file(path, new_path)
+ container = ide_traverse_to_file(path)
+
+ click_file_action(container, 'Rename/Move')
+
+ within '#ide-new-entry' do
+ find('input').fill_in(with: new_path)
+ click_button('Rename file')
+ end
+ end
+
# Deletes a file by traversing to `path`
# then clicking the 'Delete' action.
#
@@ -90,8 +103,22 @@ module WebIdeSpecHelpers
container
end
+ def ide_close_file(name)
+ within page.find('.multi-file-tabs') do
+ click_button("Close #{name}")
+ end
+ end
+
+ def ide_open_file(path)
+ row = ide_traverse_to_file(path)
+
+ ide_open_file_row(row)
+
+ wait_for_requests
+ end
+
def ide_open_file_row(row)
- return if ide_file_row_open?(row)
+ return if ide_folder_row_open?(row)
row.click
end
@@ -103,6 +130,10 @@ module WebIdeSpecHelpers
execute_script("monaco.editor.getModel('#{uri}').setValue('#{escape_javascript(value)}')")
end
+ def ide_set_editor_position(line, col)
+ execute_script("TEST_EDITOR.setPosition(#{{ lineNumber: line, column: col }.to_json})")
+ end
+
def ide_editor_value
editor = find('.monaco-editor')
uri = editor['data-uri']
@@ -149,4 +180,8 @@ module WebIdeSpecHelpers
wait_for_requests
end
end
+
+ def save_monaco_editor_reference
+ evaluate_script("monaco.editor.onDidCreateEditor(editor => { window.TEST_EDITOR = editor; })")
+ end
end
diff --git a/spec/support/helpers/graphql_helpers.rb b/spec/support/helpers/graphql_helpers.rb
index db769041f1e..a1b4e6eee92 100644
--- a/spec/support/helpers/graphql_helpers.rb
+++ b/spec/support/helpers/graphql_helpers.rb
@@ -17,8 +17,8 @@ module GraphqlHelpers
# ready, then the early return is returned instead.
#
# Then the resolve method is called.
- def resolve(resolver_class, obj: nil, args: {}, ctx: {}, field: nil)
- resolver = resolver_class.new(object: obj, context: ctx, field: field)
+ def resolve(resolver_class, args: {}, **resolver_args)
+ resolver = resolver_instance(resolver_class, **resolver_args)
ready, early_return = sync_all { resolver.ready?(**args) }
return early_return unless ready
@@ -26,6 +26,15 @@ module GraphqlHelpers
resolver.resolve(**args)
end
+ def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
+ if ctx.is_a?(Hash)
+ q = double('Query', schema: schema)
+ ctx = GraphQL::Query::Context.new(query: q, object: obj, values: ctx)
+ end
+
+ resolver_class.new(object: obj, context: ctx, field: field)
+ end
+
# Eagerly run a loader's named resolver
# (syncs any lazy values returned by resolve)
def eager_resolve(resolver_class, **opts)
@@ -112,6 +121,16 @@ module GraphqlHelpers
end
end
+ def resolve_field(name, object, args = {})
+ context = double("Context",
+ schema: GitlabSchema,
+ query: GraphQL::Query.new(GitlabSchema),
+ parent: nil)
+ field = described_class.fields[name]
+ instance = described_class.authorized_new(object, context)
+ field.resolve_field(instance, {}, context)
+ end
+
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
#
# prepare_input_for_mutation({ 'my_key' => 1 })
@@ -468,6 +487,8 @@ module GraphqlHelpers
use Gitlab::Graphql::Authorize
use Gitlab::Graphql::Pagination::Connections
+ lazy_resolve ::Gitlab::Graphql::Lazy, :force
+
query(query_type)
end
diff --git a/spec/support/helpers/kubernetes_helpers.rb b/spec/support/helpers/kubernetes_helpers.rb
index 113bb31e4be..ff61cceba06 100644
--- a/spec/support/helpers/kubernetes_helpers.rb
+++ b/spec/support/helpers/kubernetes_helpers.rb
@@ -33,8 +33,8 @@ module KubernetesHelpers
kube_response(kube_deployments_body)
end
- def kube_ingresses_response
- kube_response(kube_ingresses_body)
+ def kube_ingresses_response(with_canary: false)
+ kube_response(kube_ingresses_body(with_canary: with_canary))
end
def stub_kubeclient_discover_base(api_url)
@@ -155,12 +155,12 @@ module KubernetesHelpers
WebMock.stub_request(:get, deployments_url).to_return(response || kube_deployments_response)
end
- def stub_kubeclient_ingresses(namespace, status: nil)
+ def stub_kubeclient_ingresses(namespace, status: nil, method: :get, resource_path: "", response: kube_ingresses_response)
stub_kubeclient_discover(service.api_url)
- ingresses_url = service.api_url + "/apis/extensions/v1beta1/namespaces/#{namespace}/ingresses"
+ ingresses_url = service.api_url + "/apis/extensions/v1beta1/namespaces/#{namespace}/ingresses#{resource_path}"
response = { status: status } if status
- WebMock.stub_request(:get, ingresses_url).to_return(response || kube_ingresses_response)
+ WebMock.stub_request(method, ingresses_url).to_return(response)
end
def stub_kubeclient_knative_services(options = {})
@@ -250,6 +250,11 @@ module KubernetesHelpers
.to_return(kube_response({}))
end
+ def stub_kubeclient_delete_role_binding(api_url, name, namespace: 'default')
+ WebMock.stub_request(:delete, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{name}")
+ .to_return(kube_response({}))
+ end
+
def stub_kubeclient_put_role_binding(api_url, name, namespace: 'default')
WebMock.stub_request(:put, api_url + "/apis/rbac.authorization.k8s.io/v1/namespaces/#{namespace}/rolebindings/#{name}")
.to_return(kube_response({}))
@@ -541,10 +546,12 @@ module KubernetesHelpers
}
end
- def kube_ingresses_body
+ def kube_ingresses_body(with_canary: false)
+ items = with_canary ? [kube_ingress, kube_ingress(track: :canary)] : [kube_ingress]
+
{
"kind" => "List",
- "items" => [kube_ingress]
+ "items" => items
}
end
diff --git a/spec/support/helpers/lfs_http_helpers.rb b/spec/support/helpers/lfs_http_helpers.rb
index 0537b122040..199d5e70e32 100644
--- a/spec/support/helpers/lfs_http_helpers.rb
+++ b/spec/support/helpers/lfs_http_helpers.rb
@@ -31,16 +31,16 @@ module LfsHttpHelpers
post(url, params: params, headers: headers)
end
- def batch_url(project)
- "#{project.http_url_to_repo}/info/lfs/objects/batch"
+ def batch_url(container)
+ "#{container.http_url_to_repo}/info/lfs/objects/batch"
end
- def objects_url(project, oid = nil, size = nil)
- File.join(["#{project.http_url_to_repo}/gitlab-lfs/objects", oid, size].compact.map(&:to_s))
+ def objects_url(container, oid = nil, size = nil)
+ File.join(["#{container.http_url_to_repo}/gitlab-lfs/objects", oid, size].compact.map(&:to_s))
end
- def authorize_url(project, oid, size)
- File.join(objects_url(project, oid, size), 'authorize')
+ def authorize_url(container, oid, size)
+ File.join(objects_url(container, oid, size), 'authorize')
end
def download_body(objects)
diff --git a/spec/support/helpers/navbar_structure_helper.rb b/spec/support/helpers/navbar_structure_helper.rb
index 11e67ba394c..e18a708e41c 100644
--- a/spec/support/helpers/navbar_structure_helper.rb
+++ b/spec/support/helpers/navbar_structure_helper.rb
@@ -36,4 +36,12 @@ module NavbarStructureHelper
new_sub_nav_item_name: _('Container Registry')
)
end
+
+ def insert_dependency_proxy_nav(within)
+ insert_after_sub_nav_item(
+ _('Package Registry'),
+ within: _('Packages & Registries'),
+ new_sub_nav_item_name: _('Dependency Proxy')
+ )
+ end
end
diff --git a/spec/support/helpers/require_migration.rb b/spec/support/helpers/require_migration.rb
index d3f192a4142..c2902aa4ec7 100644
--- a/spec/support/helpers/require_migration.rb
+++ b/spec/support/helpers/require_migration.rb
@@ -3,26 +3,46 @@
require 'find'
class RequireMigration
- MIGRATION_FOLDERS = %w(db/migrate db/post_migrate ee/db/geo/migrate ee/db/geo/post_migrate).freeze
+ class AutoLoadError < RuntimeError
+ MESSAGE = "Can not find any migration file for `%{file_name}`!\n" \
+ "You can try to provide the migration file name manually."
+
+ def initialize(file_name)
+ message = format(MESSAGE, file_name: file_name)
+
+ super(message)
+ end
+ end
+
+ MIGRATION_FOLDERS = %w[db/migrate db/post_migrate].freeze
SPEC_FILE_PATTERN = /.+\/(?<file_name>.+)_spec\.rb/.freeze
class << self
def require_migration!(file_name)
file_paths = search_migration_file(file_name)
+ raise AutoLoadError.new(file_name) unless file_paths.first
require file_paths.first
end
def search_migration_file(file_name)
- MIGRATION_FOLDERS.flat_map do |path|
+ migration_folders.flat_map do |path|
migration_path = Rails.root.join(path).to_s
Find.find(migration_path).grep(/\d+_#{file_name}\.rb/)
end
end
+
+ private
+
+ def migration_folders
+ MIGRATION_FOLDERS
+ end
end
end
+RequireMigration.prepend_if_ee('EE::RequireMigration')
+
def require_migration!(file_name = nil)
location_info = caller_locations.first.path.match(RequireMigration::SPEC_FILE_PATTERN)
file_name ||= location_info[:file_name]
diff --git a/spec/support/helpers/search_helpers.rb b/spec/support/helpers/search_helpers.rb
index 328f272724a..3d4ff4801a7 100644
--- a/spec/support/helpers/search_helpers.rb
+++ b/spec/support/helpers/search_helpers.rb
@@ -3,13 +3,14 @@
module SearchHelpers
def fill_in_search(text)
page.within('.search-input-wrap') do
+ find('#search').click
fill_in('search', with: text)
end
wait_for_all_requests
end
- def submit_search(query, scope: nil)
+ def submit_search(query)
page.within('.search-form, .search-page-form') do
field = find_field('search')
field.fill_in(with: query)
diff --git a/spec/support/helpers/snowplow_helpers.rb b/spec/support/helpers/snowplow_helpers.rb
index 3bde01c6fbf..15eac1b24fc 100644
--- a/spec/support/helpers/snowplow_helpers.rb
+++ b/spec/support/helpers/snowplow_helpers.rb
@@ -32,16 +32,8 @@ module SnowplowHelpers
# end
# end
def expect_snowplow_event(category:, action:, **kwargs)
- # This check will no longer be needed with Ruby 2.7 which
- # would not pass any arguments when using **kwargs.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/263430
- if kwargs.present?
- expect(Gitlab::Tracking).to have_received(:event)
- .with(category, action, **kwargs).at_least(:once)
- else
- expect(Gitlab::Tracking).to have_received(:event)
- .with(category, action).at_least(:once)
- end
+ expect(Gitlab::Tracking).to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking
+ .with(category, action, **kwargs).at_least(:once)
end
# Asserts that no call to `Gitlab::Tracking#event` was made.
@@ -56,6 +48,6 @@ module SnowplowHelpers
# end
# end
def expect_no_snowplow_event
- expect(Gitlab::Tracking).not_to have_received(:event)
+ expect(Gitlab::Tracking).not_to have_received(:event) # rubocop:disable RSpec/ExpectGitlabTracking
end
end
diff --git a/spec/support/helpers/table_schema_helpers.rb b/spec/support/helpers/table_schema_helpers.rb
new file mode 100644
index 00000000000..28794211190
--- /dev/null
+++ b/spec/support/helpers/table_schema_helpers.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module TableSchemaHelpers
+ def connection
+ ActiveRecord::Base.connection
+ end
+
+ def expect_table_to_be_replaced(original_table:, replacement_table:, archived_table:)
+ original_oid = table_oid(original_table)
+ replacement_oid = table_oid(replacement_table)
+
+ yield
+
+ expect(table_oid(original_table)).to eq(replacement_oid)
+ expect(table_oid(archived_table)).to eq(original_oid)
+ expect(table_oid(replacement_table)).to be_nil
+ end
+
+ def expect_index_to_exist(name, schema: nil)
+ expect(index_exists_by_name(name, schema: schema)).to eq(true)
+ end
+
+ def expect_index_not_to_exist(name, schema: nil)
+ expect(index_exists_by_name(name, schema: schema)).to be_nil
+ end
+
+ def expect_primary_keys_after_tables(tables, schema: nil)
+ tables.each do |table|
+ primary_key = primary_key_constraint_name(table, schema: schema)
+
+ expect(primary_key).to eq("#{table}_pkey")
+ end
+ end
+
+ def table_oid(name)
+ connection.select_value(<<~SQL)
+ SELECT oid
+ FROM pg_catalog.pg_class
+ WHERE relname = '#{name}'
+ SQL
+ end
+
+ def table_type(name)
+ connection.select_value(<<~SQL)
+ SELECT
+ CASE class.relkind
+ WHEN 'r' THEN 'normal'
+ WHEN 'p' THEN 'partitioned'
+ ELSE 'other'
+ END as table_type
+ FROM pg_catalog.pg_class class
+ WHERE class.relname = '#{name}'
+ SQL
+ end
+
+ def sequence_owned_by(table_name, column_name)
+ connection.select_value(<<~SQL)
+ SELECT
+ sequence.relname as name
+ FROM pg_catalog.pg_class as sequence
+ INNER JOIN pg_catalog.pg_depend depend
+ ON depend.objid = sequence.oid
+ INNER JOIN pg_catalog.pg_class class
+ ON class.oid = depend.refobjid
+ INNER JOIN pg_catalog.pg_attribute attribute
+ ON attribute.attnum = depend.refobjsubid
+ AND attribute.attrelid = depend.refobjid
+ WHERE class.relname = '#{table_name}'
+ AND attribute.attname = '#{column_name}'
+ SQL
+ end
+
+ def default_expression_for(table_name, column_name)
+ connection.select_value(<<~SQL)
+ SELECT
+ pg_get_expr(attrdef.adbin, attrdef.adrelid) AS default_value
+ FROM pg_catalog.pg_attribute attribute
+ INNER JOIN pg_catalog.pg_attrdef attrdef
+ ON attribute.attrelid = attrdef.adrelid
+ AND attribute.attnum = attrdef.adnum
+ WHERE attribute.attrelid = '#{table_name}'::regclass
+ AND attribute.attname = '#{column_name}'
+ SQL
+ end
+
+ def primary_key_constraint_name(table_name, schema: nil)
+ table_name = schema ? "#{schema}.#{table_name}" : table_name
+
+ connection.select_value(<<~SQL)
+ SELECT
+ conname AS constraint_name
+ FROM pg_catalog.pg_constraint
+ WHERE pg_constraint.conrelid = '#{table_name}'::regclass
+ AND pg_constraint.contype = 'p'
+ SQL
+ end
+
+ def index_exists_by_name(index, schema: nil)
+ schema = schema ? "'#{schema}'" : 'current_schema'
+
+ connection.select_value(<<~SQL)
+ SELECT true
+ FROM pg_catalog.pg_index i
+ INNER JOIN pg_catalog.pg_class c
+ ON c.oid = i.indexrelid
+ INNER JOIN pg_catalog.pg_namespace n
+ ON c.relnamespace = n.oid
+ WHERE c.relname = '#{index}'
+ AND n.nspname = #{schema}
+ SQL
+ end
+end
diff --git a/spec/support/helpers/test_env.rb b/spec/support/helpers/test_env.rb
index 641ed24207e..4c78ca0117c 100644
--- a/spec/support/helpers/test_env.rb
+++ b/spec/support/helpers/test_env.rb
@@ -517,6 +517,8 @@ module TestEnv
return false if component_matches_git_sha?(component_folder, expected_version)
+ return false if component_ahead_of_target?(component_folder, expected_version)
+
version = File.read(File.join(component_folder, 'VERSION')).strip
# Notice that this will always yield true when using branch versions
@@ -527,6 +529,20 @@ module TestEnv
true
end
+ def component_ahead_of_target?(component_folder, expected_version)
+ # The HEAD of the component_folder will be used as heuristic for the version
+ # of the binaries, allowing to use Git to determine if HEAD is later than
+ # the expected version. Note: Git considers HEAD to be an anchestor of HEAD.
+ _out, exit_status = Gitlab::Popen.popen(%W[
+ #{Gitlab.config.git.bin_path}
+ -C #{component_folder}
+ merge-base --is-ancestor
+ #{expected_version} HEAD
+])
+
+ exit_status == 0
+ end
+
def component_matches_git_sha?(component_folder, expected_version)
# Not a git SHA, so return early
return false unless expected_version =~ ::Gitlab::Git::COMMIT_ID
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index 2592d9f8b42..8e8aeea2ea1 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -98,6 +98,7 @@ module UsageDataHelpers
projects_with_repositories_enabled
projects_with_error_tracking_enabled
projects_with_alerts_service_enabled
+ projects_with_enabled_alert_integrations
projects_with_prometheus_alerts
projects_with_tracing_enabled
projects_with_expiration_policy_enabled
diff --git a/spec/support/helpers/user_login_helper.rb b/spec/support/helpers/user_login_helper.rb
index 66606832883..47e858cb68c 100644
--- a/spec/support/helpers/user_login_helper.rb
+++ b/spec/support/helpers/user_login_helper.rb
@@ -1,18 +1,25 @@
# frozen_string_literal: true
module UserLoginHelper
- def ensure_tab_pane_correctness(visit_path = true)
- if visit_path
- visit new_user_session_path
- end
-
- ensure_tab_pane_counts
+ def ensure_tab_pane_correctness(tab_names)
+ ensure_tab_pane_counts(tab_names.size)
+ ensure_tab_labels(tab_names)
ensure_one_active_tab
ensure_one_active_pane
end
- def ensure_tab_pane_counts
- tabs_count = page.all('[role="tab"]').size
+ def ensure_no_tabs
+ expect(page.all('[role="tab"]').size).to eq(0)
+ end
+
+ def ensure_tab_labels(tab_names)
+ tab_labels = page.all('[role="tab"]').map(&:text)
+
+ expect(tab_names).to match_array(tab_labels)
+ end
+
+ def ensure_tab_pane_counts(tabs_count)
+ expect(page.all('[role="tab"]').size).to eq(tabs_count)
expect(page).to have_selector('[role="tabpanel"]', visible: :all, count: tabs_count)
end
diff --git a/spec/support/helpers/wiki_helpers.rb b/spec/support/helpers/wiki_helpers.rb
index 8873a90579d..e276c896da2 100644
--- a/spec/support/helpers/wiki_helpers.rb
+++ b/spec/support/helpers/wiki_helpers.rb
@@ -4,7 +4,6 @@ module WikiHelpers
extend self
def stub_group_wikis(enabled)
- stub_feature_flags(group_wikis: enabled)
stub_licensed_features(group_wikis: enabled)
end
diff --git a/spec/support/import_export/common_util.rb b/spec/support/import_export/common_util.rb
index c0c3559cca0..ae951ea35af 100644
--- a/spec/support/import_export/common_util.rb
+++ b/spec/support/import_export/common_util.rb
@@ -15,7 +15,7 @@ module ImportExport
export_path = [prefix, 'spec', 'fixtures', 'lib', 'gitlab', 'import_export', name].compact
export_path = File.join(*export_path)
- allow_any_instance_of(Gitlab::ImportExport).to receive(:export_path) { export_path }
+ allow(Gitlab::ImportExport).to receive(:export_path) { export_path }
end
def setup_reader(reader)
diff --git a/spec/support/matchers/graphql_matchers.rb b/spec/support/matchers/graphql_matchers.rb
index 7fa06e25405..8c4ba387a74 100644
--- a/spec/support/matchers/graphql_matchers.rb
+++ b/spec/support/matchers/graphql_matchers.rb
@@ -109,15 +109,82 @@ RSpec::Matchers.define :have_graphql_arguments do |*expected|
end
end
-RSpec::Matchers.define :have_graphql_type do |expected|
- match do |field|
- expect(field.type).to eq(expected)
+module GraphQLTypeHelpers
+ def message(object, expected, **opts)
+ non_null = expected.non_null? || (opts.key?(:null) && !opts[:null])
+
+ actual = object.type
+ actual_type = actual.unwrap.graphql_name
+ actual_type += '!' if actual.non_null?
+
+ expected_type = expected.unwrap.graphql_name
+ expected_type += '!' if non_null
+
+ "expected #{describe_object(object)} to have GraphQL type #{expected_type}, but got #{actual_type}"
+ end
+
+ def describe_object(object)
+ case object
+ when Types::BaseField
+ "#{describe_object(object.owner_type)}.#{object.graphql_name}"
+ when Types::BaseArgument
+ "#{describe_object(object.owner)}.#{object.graphql_name}"
+ when Class
+ object.try(:graphql_name) || object.name
+ else
+ object.to_s
+ end
+ end
+
+ def nullified(type, can_be_nil)
+ return type if can_be_nil.nil? # unknown!
+ return type if can_be_nil
+
+ type.to_non_null_type
+ end
+end
+
+RSpec::Matchers.define :have_graphql_type do |expected, opts = {}|
+ include GraphQLTypeHelpers
+
+ match do |object|
+ expect(object.type).to eq(nullified(expected, opts[:null]))
+ end
+
+ failure_message do |object|
+ message(object, expected, **opts)
+ end
+end
+
+RSpec::Matchers.define :have_nullable_graphql_type do |expected|
+ include GraphQLTypeHelpers
+
+ match do |object|
+ expect(object).to have_graphql_type(expected.unwrap, { null: true })
+ end
+
+ description do
+ "have nullable GraphQL type #{expected.graphql_name}"
+ end
+
+ failure_message do |object|
+ message(object, expected, null: true)
end
end
RSpec::Matchers.define :have_non_null_graphql_type do |expected|
- match do |field|
- expect(field.type.to_graphql).to eq(!expected.to_graphql)
+ include GraphQLTypeHelpers
+
+ match do |object|
+ expect(object).to have_graphql_type(expected, { null: false })
+ end
+
+ description do
+ "have non-null GraphQL type #{expected.graphql_name}"
+ end
+
+ failure_message do |object|
+ message(object, expected, null: false)
end
end
diff --git a/spec/support/patches/rspec_mocks_prepended_methods.rb b/spec/support/patches/rspec_mocks_prepended_methods.rb
new file mode 100644
index 00000000000..fa3a74c670c
--- /dev/null
+++ b/spec/support/patches/rspec_mocks_prepended_methods.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+# This patch allows stubbing of prepended methods
+# Based on https://github.com/rspec/rspec-mocks/pull/1218
+
+module RSpec
+ module Mocks
+ module InstanceMethodStasherForPrependedMethods
+ private
+
+ def method_owned_by_klass?
+ owner = @klass.instance_method(@method).owner
+ owner = owner.class unless Module === owner
+
+ owner == @klass ||
+ # When `extend self` is used, and not under any instance of
+ (owner.singleton_class == @klass && !Mocks.space.any_instance_recorder_for(owner, true)) ||
+ !method_defined_on_klass?(owner)
+ end
+ end
+ end
+end
+
+module RSpec
+ module Mocks
+ module MethodDoubleForPrependedMethods
+ def restore_original_method
+ return show_frozen_warning if object_singleton_class.frozen?
+ return unless @method_is_proxied
+
+ remove_method_from_definition_target
+
+ if @method_stasher.method_is_stashed?
+ @method_stasher.restore
+ restore_original_visibility
+ end
+
+ @method_is_proxied = false
+ end
+
+ def restore_original_visibility
+ method_owner.__send__(@original_visibility, @method_name)
+ end
+
+ private
+
+ def method_owner
+ @method_owner ||= Object.instance_method(:method).bind(object).call(@method_name).owner
+ end
+ end
+ end
+end
+
+RSpec::Mocks::InstanceMethodStasher.prepend(RSpec::Mocks::InstanceMethodStasherForPrependedMethods)
+RSpec::Mocks::MethodDouble.prepend(RSpec::Mocks::MethodDoubleForPrependedMethods)
diff --git a/spec/support/rspec.rb b/spec/support/rspec.rb
index 861b57c9efa..32f738faa9b 100644
--- a/spec/support/rspec.rb
+++ b/spec/support/rspec.rb
@@ -5,6 +5,13 @@ require_relative "helpers/stub_metrics"
require_relative "helpers/stub_object_storage"
require_relative "helpers/stub_env"
require_relative "helpers/fast_rails_root"
+
+# so we need to load rubocop here due to the rubocop support file loading cop_helper
+# which monkey patches class Cop
+# if cop helper is loaded before rubocop (where class Cop is defined as class Cop < Base)
+# we get a `superclass mismatch for class Cop` error when running a rspec for a locally defined
+# rubocop cop - therefore we need rubocop required first since it had an inheritance added to the Cop class
+require 'rubocop'
require 'rubocop/rspec/support'
RSpec.configure do |config|
diff --git a/spec/support/services/issuable_import_csv_service_shared_examples.rb b/spec/support/services/issuable_import_csv_service_shared_examples.rb
new file mode 100644
index 00000000000..20ac2ff5c7c
--- /dev/null
+++ b/spec/support/services/issuable_import_csv_service_shared_examples.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.shared_examples 'issuable import csv service' do |issuable_type|
+ let_it_be_with_refind(:project) { create(:project) }
+ let_it_be(:user) { create(:user) }
+
+ subject { service.execute }
+
+ shared_examples_for 'an issuable importer' do
+ if issuable_type == 'issue'
+ it 'records the import attempt if resource is an issue' do
+ expect { subject }
+ .to change { Issues::CsvImport.where(project: project, user: user).count }
+ .by 1
+ end
+ end
+ end
+
+ shared_examples_for 'importer with email notification' do
+ it 'notifies user of import result' do
+ expect(Notify).to receive_message_chain(email_method, :deliver_later)
+
+ subject
+ end
+ end
+
+ describe '#execute' do
+ context 'invalid file' do
+ let(:file) { fixture_file_upload('spec/fixtures/banana_sample.gif') }
+
+ it 'returns invalid file error' do
+ expect(subject[:success]).to eq(0)
+ expect(subject[:parse_error]).to eq(true)
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'file without headers' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_no_headers.csv') }
+
+ it 'returns invalid file error' do
+ expect(subject[:success]).to eq(0)
+ expect(subject[:parse_error]).to eq(true)
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'with a file generated by Gitlab CSV export' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_gitlab_export.csv') }
+
+ it 'imports the CSV without errors' do
+ expect(subject[:success]).to eq(4)
+ expect(subject[:error_lines]).to eq([])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 4
+
+ expect(issuables.reload.last).to have_attributes(
+ title: 'Test Title',
+ description: 'Test Description'
+ )
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'comma delimited file' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_comma.csv') }
+
+ it 'imports CSV without errors' do
+ expect(subject[:success]).to eq(3)
+ expect(subject[:error_lines]).to eq([])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 3
+
+ expect(issuables.reload.last).to have_attributes(
+ title: 'Title with quote"',
+ description: 'Description'
+ )
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'tab delimited file with error row' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_tab.csv') }
+
+ it 'imports CSV with some error rows' do
+ expect(subject[:success]).to eq(2)
+ expect(subject[:error_lines]).to eq([3])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 2
+
+ expect(issuables.reload.last).to have_attributes(
+ title: 'Hello',
+ description: 'World'
+ )
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+
+ context 'semicolon delimited file with CRLF' do
+ let(:file) { fixture_file_upload('spec/fixtures/csv_semicolon.csv') }
+
+ it 'imports CSV with a blank row' do
+ expect(subject[:success]).to eq(3)
+ expect(subject[:error_lines]).to eq([4])
+ expect(subject[:parse_error]).to eq(false)
+ end
+
+ it 'correctly sets the issuable attributes' do
+ expect { subject }.to change { issuables.count }.by 3
+
+ expect(issuables.reload.last).to have_attributes(
+ title: 'Hello',
+ description: 'World'
+ )
+ end
+
+ it_behaves_like 'importer with email notification'
+ it_behaves_like 'an issuable importer'
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/design_management_shared_contexts.rb b/spec/support/shared_contexts/design_management_shared_contexts.rb
index 3ff6a521338..e6ae7e03664 100644
--- a/spec/support/shared_contexts/design_management_shared_contexts.rb
+++ b/spec/support/shared_contexts/design_management_shared_contexts.rb
@@ -18,12 +18,14 @@ RSpec.shared_context 'four designs in three versions' do
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: [],
diff --git a/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb
index 2b6edb4c07d..68ff16922d8 100644
--- a/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/group_projects_finder_shared_contexts.rb
@@ -1,21 +1,21 @@
# frozen_string_literal: true
RSpec.shared_context 'GroupProjectsFinder context' do
- let(:group) { create(:group) }
- let(:subgroup) { create(:group, parent: group) }
- let(:current_user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:current_user) { create(:user) }
let(:params) { {} }
let(:options) { {} }
let(:finder) { described_class.new(group: group, current_user: current_user, params: params, options: options) }
- let!(:public_project) { create(:project, :public, group: group, path: '1') }
- let!(:private_project) { create(:project, :private, group: group, path: '2') }
- let!(:shared_project_1) { create(:project, :public, path: '3') }
- let!(:shared_project_2) { create(:project, :private, path: '4') }
- let!(:shared_project_3) { create(:project, :internal, path: '5') }
- let!(:subgroup_project) { create(:project, :public, path: '6', group: subgroup) }
- let!(:subgroup_private_project) { create(:project, :private, path: '7', group: subgroup) }
+ let_it_be(:public_project) { create(:project, :public, group: group, path: '1') }
+ let_it_be(:private_project) { create(:project, :private, group: group, path: '2') }
+ let_it_be(:shared_project_1) { create(:project, :public, path: '3') }
+ let_it_be(:shared_project_2) { create(:project, :private, path: '4') }
+ let_it_be(:shared_project_3) { create(:project, :internal, path: '5') }
+ let_it_be(:subgroup_project) { create(:project, :public, path: '6', group: subgroup) }
+ let_it_be(:subgroup_private_project) { create(:project, :private, path: '7', group: subgroup) }
before do
shared_project_1.project_group_links.create!(group_access: Gitlab::Access::MAINTAINER, group: group)
diff --git a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
index 010c445d8df..88c31bf9cfd 100644
--- a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
+++ b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb
@@ -23,6 +23,7 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests
# We cannot use `let_it_be` here otherwise we get:
# Failure/Error: allow(RepositoryForkWorker).to receive(:perform_async).and_return(true)
# The use of doubles or partial doubles from rspec-mocks outside of the per-test lifecycle is not supported.
+
let!(:project2) do
allow_gitaly_n_plus_1 do
fork_project(project1, user)
@@ -40,9 +41,11 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests
let_it_be(:project4, reload: true) do
allow_gitaly_n_plus_1 { create(:project, :repository, group: subgroup) }
end
+
let_it_be(:project5, reload: true) do
allow_gitaly_n_plus_1 { create(:project, group: subgroup) }
end
+
let_it_be(:project6, reload: true) do
allow_gitaly_n_plus_1 { create(:project, group: subgroup) }
end
diff --git a/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb b/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb
new file mode 100644
index 00000000000..3830b89f1ff
--- /dev/null
+++ b/spec/support/shared_contexts/lib/gitlab/middleware/with_a_mocked_gitlab_instance_shared_context.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'with a mocked GitLab instance' do
+ let(:rack_stack) do
+ rack = Rack::Builder.new do
+ use ActionDispatch::Session::CacheStore
+ use ActionDispatch::Flash
+ end
+
+ rack.run(subject)
+ rack.to_app
+ end
+
+ let(:observe_env) do
+ Module.new do
+ attr_reader :env
+
+ def call(env)
+ @env = env
+ super
+ end
+ end
+ end
+
+ let(:request) { Rack::MockRequest.new(rack_stack) }
+
+ subject do
+ described_class.new(fake_app).tap do |app|
+ app.extend(observe_env)
+ end
+ end
+end
diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb
index 9ebfdcb9522..ed74c3f179f 100644
--- a/spec/support/shared_contexts/navbar_structure_context.rb
+++ b/spec/support/shared_contexts/navbar_structure_context.rb
@@ -56,6 +56,7 @@ RSpec.shared_context 'project navbar structure' do
nav_item: _('CI / CD'),
nav_sub_items: [
_('Pipelines'),
+ s_('Pipelines|Editor'),
_('Jobs'),
_('Artifacts'),
_('Schedules')
@@ -71,6 +72,7 @@ RSpec.shared_context 'project navbar structure' do
_('Alerts'),
_('Incidents'),
_('Serverless'),
+ _('Terraform'),
_('Kubernetes'),
_('Environments'),
_('Feature Flags'),
diff --git a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
index efd82ecb15a..8c9a60fa703 100644
--- a/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
+++ b/spec/support/shared_contexts/policies/project_policy_table_shared_context.rb
@@ -3,6 +3,8 @@
RSpec.shared_context 'ProjectPolicyTable context' do
using RSpec::Parameterized::TableSyntax
+ include AdminModeHelper
+
let(:pendings) { {} }
let(:pending?) do
pendings.include?(
@@ -10,106 +12,117 @@ RSpec.shared_context 'ProjectPolicyTable context' do
project_level: project_level,
feature_access_level: feature_access_level,
membership: membership,
+ admin_mode: admin_mode,
expected_count: expected_count
}
)
end
# rubocop:disable Metrics/AbcSize
- # project_level, :feature_access_level, :membership, :expected_count
+ # project_level, :feature_access_level, :membership, :admin_mode, :expected_count
def permission_table_for_reporter_feature_access
- :public | :enabled | :admin | 1
- :public | :enabled | :reporter | 1
- :public | :enabled | :guest | 1
- :public | :enabled | :non_member | 1
- :public | :enabled | :anonymous | 1
-
- :public | :private | :admin | 1
- :public | :private | :reporter | 1
- :public | :private | :guest | 0
- :public | :private | :non_member | 0
- :public | :private | :anonymous | 0
-
- :public | :disabled | :reporter | 0
- :public | :disabled | :guest | 0
- :public | :disabled | :non_member | 0
- :public | :disabled | :anonymous | 0
-
- :internal | :enabled | :admin | 1
- :internal | :enabled | :reporter | 1
- :internal | :enabled | :guest | 1
- :internal | :enabled | :non_member | 1
- :internal | :enabled | :anonymous | 0
-
- :internal | :private | :admin | 1
- :internal | :private | :reporter | 1
- :internal | :private | :guest | 0
- :internal | :private | :non_member | 0
- :internal | :private | :anonymous | 0
-
- :internal | :disabled | :reporter | 0
- :internal | :disabled | :guest | 0
- :internal | :disabled | :non_member | 0
- :internal | :disabled | :anonymous | 0
-
- :private | :private | :admin | 1
- :private | :private | :reporter | 1
- :private | :private | :guest | 0
- :private | :private | :non_member | 0
- :private | :private | :anonymous | 0
-
- :private | :disabled | :reporter | 0
- :private | :disabled | :guest | 0
- :private | :disabled | :non_member | 0
- :private | :disabled | :anonymous | 0
+ :public | :enabled | :admin | true | 1
+ :public | :enabled | :admin | false | 1
+ :public | :enabled | :reporter | nil | 1
+ :public | :enabled | :guest | nil | 1
+ :public | :enabled | :non_member | nil | 1
+ :public | :enabled | :anonymous | nil | 1
+
+ :public | :private | :admin | true | 1
+ :public | :private | :admin | false | 0
+ :public | :private | :reporter | nil | 1
+ :public | :private | :guest | nil | 0
+ :public | :private | :non_member | nil | 0
+ :public | :private | :anonymous | nil | 0
+
+ :public | :disabled | :reporter | nil | 0
+ :public | :disabled | :guest | nil | 0
+ :public | :disabled | :non_member | nil | 0
+ :public | :disabled | :anonymous | nil | 0
+
+ :internal | :enabled | :admin | true | 1
+ :internal | :enabled | :admin | false | 1
+ :internal | :enabled | :reporter | nil | 1
+ :internal | :enabled | :guest | nil | 1
+ :internal | :enabled | :non_member | nil | 1
+ :internal | :enabled | :anonymous | nil | 0
+
+ :internal | :private | :admin | true | 1
+ :internal | :private | :admin | false | 0
+ :internal | :private | :reporter | nil | 1
+ :internal | :private | :guest | nil | 0
+ :internal | :private | :non_member | nil | 0
+ :internal | :private | :anonymous | nil | 0
+
+ :internal | :disabled | :reporter | nil | 0
+ :internal | :disabled | :guest | nil | 0
+ :internal | :disabled | :non_member | nil | 0
+ :internal | :disabled | :anonymous | nil | 0
+
+ :private | :private | :admin | true | 1
+ :private | :private | :admin | false | 0
+ :private | :private | :reporter | nil | 1
+ :private | :private | :guest | nil | 0
+ :private | :private | :non_member | nil | 0
+ :private | :private | :anonymous | nil | 0
+
+ :private | :disabled | :reporter | nil | 0
+ :private | :disabled | :guest | nil | 0
+ :private | :disabled | :non_member | nil | 0
+ :private | :disabled | :anonymous | nil | 0
end
- # project_level, :feature_access_level, :membership, :expected_count
+ # project_level, :feature_access_level, :membership, :admin_mode, :expected_count
def permission_table_for_guest_feature_access
- :public | :enabled | :admin | 1
- :public | :enabled | :reporter | 1
- :public | :enabled | :guest | 1
- :public | :enabled | :non_member | 1
- :public | :enabled | :anonymous | 1
-
- :public | :private | :admin | 1
- :public | :private | :reporter | 1
- :public | :private | :guest | 1
- :public | :private | :non_member | 0
- :public | :private | :anonymous | 0
-
- :public | :disabled | :reporter | 0
- :public | :disabled | :guest | 0
- :public | :disabled | :non_member | 0
- :public | :disabled | :anonymous | 0
-
- :internal | :enabled | :admin | 1
- :internal | :enabled | :reporter | 1
- :internal | :enabled | :guest | 1
- :internal | :enabled | :non_member | 1
- :internal | :enabled | :anonymous | 0
-
- :internal | :private | :admin | 1
- :internal | :private | :reporter | 1
- :internal | :private | :guest | 1
- :internal | :private | :non_member | 0
- :internal | :private | :anonymous | 0
-
- :internal | :disabled | :reporter | 0
- :internal | :disabled | :guest | 0
- :internal | :disabled | :non_member | 0
- :internal | :disabled | :anonymous | 0
-
- :private | :private | :admin | 1
- :private | :private | :reporter | 1
- :private | :private | :guest | 1
- :private | :private | :non_member | 0
- :private | :private | :anonymous | 0
-
- :private | :disabled | :reporter | 0
- :private | :disabled | :guest | 0
- :private | :disabled | :non_member | 0
- :private | :disabled | :anonymous | 0
+ :public | :enabled | :admin | true | 1
+ :public | :enabled | :admin | false | 1
+ :public | :enabled | :reporter | nil | 1
+ :public | :enabled | :guest | nil | 1
+ :public | :enabled | :non_member | nil | 1
+ :public | :enabled | :anonymous | nil | 1
+
+ :public | :private | :admin | true | 1
+ :public | :private | :admin | false | 0
+ :public | :private | :reporter | nil | 1
+ :public | :private | :guest | nil | 1
+ :public | :private | :non_member | nil | 0
+ :public | :private | :anonymous | nil | 0
+
+ :public | :disabled | :reporter | nil | 0
+ :public | :disabled | :guest | nil | 0
+ :public | :disabled | :non_member | nil | 0
+ :public | :disabled | :anonymous | nil | 0
+
+ :internal | :enabled | :admin | true | 1
+ :internal | :enabled | :admin | false | 1
+ :internal | :enabled | :reporter | nil | 1
+ :internal | :enabled | :guest | nil | 1
+ :internal | :enabled | :non_member | nil | 1
+ :internal | :enabled | :anonymous | nil | 0
+
+ :internal | :private | :admin | true | 1
+ :internal | :private | :admin | false | 0
+ :internal | :private | :reporter | nil | 1
+ :internal | :private | :guest | nil | 1
+ :internal | :private | :non_member | nil | 0
+ :internal | :private | :anonymous | nil | 0
+
+ :internal | :disabled | :reporter | nil | 0
+ :internal | :disabled | :guest | nil | 0
+ :internal | :disabled | :non_member | nil | 0
+ :internal | :disabled | :anonymous | nil | 0
+
+ :private | :private | :admin | true | 1
+ :private | :private | :admin | false | 0
+ :private | :private | :reporter | nil | 1
+ :private | :private | :guest | nil | 1
+ :private | :private | :non_member | nil | 0
+ :private | :private | :anonymous | nil | 0
+
+ :private | :disabled | :reporter | nil | 0
+ :private | :disabled | :guest | nil | 0
+ :private | :disabled | :non_member | nil | 0
+ :private | :disabled | :anonymous | nil | 0
end
# This table is based on permission_table_for_guest_feature_access,
@@ -121,184 +134,208 @@ RSpec.shared_context 'ProjectPolicyTable context' do
# e.g. `repository` feature has minimum requirement of GUEST,
# but a GUEST are prohibited from reading code if project is private.
#
- # project_level, :feature_access_level, :membership, :expected_count
+ # project_level, :feature_access_level, :membership, :admin_mode, :expected_count
def permission_table_for_guest_feature_access_and_non_private_project_only
- :public | :enabled | :admin | 1
- :public | :enabled | :reporter | 1
- :public | :enabled | :guest | 1
- :public | :enabled | :non_member | 1
- :public | :enabled | :anonymous | 1
-
- :public | :private | :admin | 1
- :public | :private | :reporter | 1
- :public | :private | :guest | 1
- :public | :private | :non_member | 0
- :public | :private | :anonymous | 0
-
- :public | :disabled | :reporter | 0
- :public | :disabled | :guest | 0
- :public | :disabled | :non_member | 0
- :public | :disabled | :anonymous | 0
-
- :internal | :enabled | :admin | 1
- :internal | :enabled | :reporter | 1
- :internal | :enabled | :guest | 1
- :internal | :enabled | :non_member | 1
- :internal | :enabled | :anonymous | 0
-
- :internal | :private | :admin | 1
- :internal | :private | :reporter | 1
- :internal | :private | :guest | 1
- :internal | :private | :non_member | 0
- :internal | :private | :anonymous | 0
-
- :internal | :disabled | :reporter | 0
- :internal | :disabled | :guest | 0
- :internal | :disabled | :non_member | 0
- :internal | :disabled | :anonymous | 0
-
- :private | :private | :admin | 1
- :private | :private | :reporter | 1
- :private | :private | :guest | 0
- :private | :private | :non_member | 0
- :private | :private | :anonymous | 0
-
- :private | :disabled | :reporter | 0
- :private | :disabled | :guest | 0
- :private | :disabled | :non_member | 0
- :private | :disabled | :anonymous | 0
+ :public | :enabled | :admin | true | 1
+ :public | :enabled | :admin | false | 1
+ :public | :enabled | :reporter | nil | 1
+ :public | :enabled | :guest | nil | 1
+ :public | :enabled | :non_member | nil | 1
+ :public | :enabled | :anonymous | nil | 1
+
+ :public | :private | :admin | true | 1
+ :public | :private | :admin | false | 0
+ :public | :private | :reporter | nil | 1
+ :public | :private | :guest | nil | 1
+ :public | :private | :non_member | nil | 0
+ :public | :private | :anonymous | nil | 0
+
+ :public | :disabled | :reporter | nil | 0
+ :public | :disabled | :guest | nil | 0
+ :public | :disabled | :non_member | nil | 0
+ :public | :disabled | :anonymous | nil | 0
+
+ :internal | :enabled | :admin | true | 1
+ :internal | :enabled | :admin | false | 1
+ :internal | :enabled | :reporter | nil | 1
+ :internal | :enabled | :guest | nil | 1
+ :internal | :enabled | :non_member | nil | 1
+ :internal | :enabled | :anonymous | nil | 0
+
+ :internal | :private | :admin | true | 1
+ :internal | :private | :admin | false | 0
+ :internal | :private | :reporter | nil | 1
+ :internal | :private | :guest | nil | 1
+ :internal | :private | :non_member | nil | 0
+ :internal | :private | :anonymous | nil | 0
+
+ :internal | :disabled | :reporter | nil | 0
+ :internal | :disabled | :guest | nil | 0
+ :internal | :disabled | :non_member | nil | 0
+ :internal | :disabled | :anonymous | nil | 0
+
+ :private | :private | :admin | true | 1
+ :private | :private | :admin | false | 0
+ :private | :private | :reporter | nil | 1
+ :private | :private | :guest | nil | 0
+ :private | :private | :non_member | nil | 0
+ :private | :private | :anonymous | nil | 0
+
+ :private | :disabled | :reporter | nil | 0
+ :private | :disabled | :guest | nil | 0
+ :private | :disabled | :non_member | nil | 0
+ :private | :disabled | :anonymous | nil | 0
end
- # :project_level, :issues_access_level, :merge_requests_access_level, :membership, :expected_count
+ # :project_level, :issues_access_level, :merge_requests_access_level, :membership, :admin_mode, :expected_count
def permission_table_for_milestone_access
- :public | :enabled | :enabled | :admin | 1
- :public | :enabled | :enabled | :reporter | 1
- :public | :enabled | :enabled | :guest | 1
- :public | :enabled | :enabled | :non_member | 1
- :public | :enabled | :enabled | :anonymous | 1
-
- :public | :enabled | :private | :admin | 1
- :public | :enabled | :private | :reporter | 1
- :public | :enabled | :private | :guest | 1
- :public | :enabled | :private | :non_member | 1
- :public | :enabled | :private | :anonymous | 1
-
- :public | :enabled | :disabled | :admin | 1
- :public | :enabled | :disabled | :reporter | 1
- :public | :enabled | :disabled | :guest | 1
- :public | :enabled | :disabled | :non_member | 1
- :public | :enabled | :disabled | :anonymous | 1
-
- :public | :private | :enabled | :admin | 1
- :public | :private | :enabled | :reporter | 1
- :public | :private | :enabled | :guest | 1
- :public | :private | :enabled | :non_member | 1
- :public | :private | :enabled | :anonymous | 1
-
- :public | :private | :private | :admin | 1
- :public | :private | :private | :reporter | 1
- :public | :private | :private | :guest | 1
- :public | :private | :private | :non_member | 0
- :public | :private | :private | :anonymous | 0
-
- :public | :private | :disabled | :admin | 1
- :public | :private | :disabled | :reporter | 1
- :public | :private | :disabled | :guest | 1
- :public | :private | :disabled | :non_member | 0
- :public | :private | :disabled | :anonymous | 0
-
- :public | :disabled | :enabled | :admin | 1
- :public | :disabled | :enabled | :reporter | 1
- :public | :disabled | :enabled | :guest | 1
- :public | :disabled | :enabled | :non_member | 1
- :public | :disabled | :enabled | :anonymous | 1
-
- :public | :disabled | :private | :admin | 1
- :public | :disabled | :private | :reporter | 1
- :public | :disabled | :private | :guest | 0
- :public | :disabled | :private | :non_member | 0
- :public | :disabled | :private | :anonymous | 0
-
- :public | :disabled | :disabled | :reporter | 0
- :public | :disabled | :disabled | :guest | 0
- :public | :disabled | :disabled | :non_member | 0
- :public | :disabled | :disabled | :anonymous | 0
-
- :internal | :enabled | :enabled | :admin | 1
- :internal | :enabled | :enabled | :reporter | 1
- :internal | :enabled | :enabled | :guest | 1
- :internal | :enabled | :enabled | :non_member | 1
- :internal | :enabled | :enabled | :anonymous | 0
-
- :internal | :enabled | :private | :admin | 1
- :internal | :enabled | :private | :reporter | 1
- :internal | :enabled | :private | :guest | 1
- :internal | :enabled | :private | :non_member | 1
- :internal | :enabled | :private | :anonymous | 0
-
- :internal | :enabled | :disabled | :admin | 1
- :internal | :enabled | :disabled | :reporter | 1
- :internal | :enabled | :disabled | :guest | 1
- :internal | :enabled | :disabled | :non_member | 1
- :internal | :enabled | :disabled | :anonymous | 0
-
- :internal | :private | :enabled | :admin | 1
- :internal | :private | :enabled | :reporter | 1
- :internal | :private | :enabled | :guest | 1
- :internal | :private | :enabled | :non_member | 1
- :internal | :private | :enabled | :anonymous | 0
-
- :internal | :private | :private | :admin | 1
- :internal | :private | :private | :reporter | 1
- :internal | :private | :private | :guest | 1
- :internal | :private | :private | :non_member | 0
- :internal | :private | :private | :anonymous | 0
-
- :internal | :private | :disabled | :admin | 1
- :internal | :private | :disabled | :reporter | 1
- :internal | :private | :disabled | :guest | 1
- :internal | :private | :disabled | :non_member | 0
- :internal | :private | :disabled | :anonymous | 0
-
- :internal | :disabled | :enabled | :admin | 1
- :internal | :disabled | :enabled | :reporter | 1
- :internal | :disabled | :enabled | :guest | 1
- :internal | :disabled | :enabled | :non_member | 1
- :internal | :disabled | :enabled | :anonymous | 0
-
- :internal | :disabled | :private | :admin | 1
- :internal | :disabled | :private | :reporter | 1
- :internal | :disabled | :private | :guest | 0
- :internal | :disabled | :private | :non_member | 0
- :internal | :disabled | :private | :anonymous | 0
-
- :internal | :disabled | :disabled | :reporter | 0
- :internal | :disabled | :disabled | :guest | 0
- :internal | :disabled | :disabled | :non_member | 0
- :internal | :disabled | :disabled | :anonymous | 0
-
- :private | :private | :private | :admin | 1
- :private | :private | :private | :reporter | 1
- :private | :private | :private | :guest | 1
- :private | :private | :private | :non_member | 0
- :private | :private | :private | :anonymous | 0
-
- :private | :private | :disabled | :admin | 1
- :private | :private | :disabled | :reporter | 1
- :private | :private | :disabled | :guest | 1
- :private | :private | :disabled | :non_member | 0
- :private | :private | :disabled | :anonymous | 0
-
- :private | :disabled | :private | :admin | 1
- :private | :disabled | :private | :reporter | 1
- :private | :disabled | :private | :guest | 0
- :private | :disabled | :private | :non_member | 0
- :private | :disabled | :private | :anonymous | 0
-
- :private | :disabled | :disabled | :reporter | 0
- :private | :disabled | :disabled | :guest | 0
- :private | :disabled | :disabled | :non_member | 0
- :private | :disabled | :disabled | :anonymous | 0
+ :public | :enabled | :enabled | :admin | true | 1
+ :public | :enabled | :enabled | :admin | false | 1
+ :public | :enabled | :enabled | :reporter | nil | 1
+ :public | :enabled | :enabled | :guest | nil | 1
+ :public | :enabled | :enabled | :non_member | nil | 1
+ :public | :enabled | :enabled | :anonymous | nil | 1
+
+ :public | :enabled | :private | :admin | true | 1
+ :public | :enabled | :private | :admin | false | 1
+ :public | :enabled | :private | :reporter | nil | 1
+ :public | :enabled | :private | :guest | nil | 1
+ :public | :enabled | :private | :non_member | nil | 1
+ :public | :enabled | :private | :anonymous | nil | 1
+
+ :public | :enabled | :disabled | :admin | true | 1
+ :public | :enabled | :disabled | :admin | false | 1
+ :public | :enabled | :disabled | :reporter | nil | 1
+ :public | :enabled | :disabled | :guest | nil | 1
+ :public | :enabled | :disabled | :non_member | nil | 1
+ :public | :enabled | :disabled | :anonymous | nil | 1
+
+ :public | :private | :enabled | :admin | true | 1
+ :public | :private | :enabled | :admin | false | 1
+ :public | :private | :enabled | :reporter | nil | 1
+ :public | :private | :enabled | :guest | nil | 1
+ :public | :private | :enabled | :non_member | nil | 1
+ :public | :private | :enabled | :anonymous | nil | 1
+
+ :public | :private | :private | :admin | true | 1
+ :public | :private | :private | :admin | false | 0
+ :public | :private | :private | :reporter | nil | 1
+ :public | :private | :private | :guest | nil | 1
+ :public | :private | :private | :non_member | nil | 0
+ :public | :private | :private | :anonymous | nil | 0
+
+ :public | :private | :disabled | :admin | true | 1
+ :public | :private | :disabled | :admin | false | 0
+ :public | :private | :disabled | :reporter | nil | 1
+ :public | :private | :disabled | :guest | nil | 1
+ :public | :private | :disabled | :non_member | nil | 0
+ :public | :private | :disabled | :anonymous | nil | 0
+
+ :public | :disabled | :enabled | :admin | true | 1
+ :public | :disabled | :enabled | :admin | false | 1
+ :public | :disabled | :enabled | :reporter | nil | 1
+ :public | :disabled | :enabled | :guest | nil | 1
+ :public | :disabled | :enabled | :non_member | nil | 1
+ :public | :disabled | :enabled | :anonymous | nil | 1
+
+ :public | :disabled | :private | :admin | true | 1
+ :public | :disabled | :private | :admin | false | 0
+ :public | :disabled | :private | :reporter | nil | 1
+ :public | :disabled | :private | :guest | nil | 0
+ :public | :disabled | :private | :non_member | nil | 0
+ :public | :disabled | :private | :anonymous | nil | 0
+
+ :public | :disabled | :disabled | :reporter | nil | 0
+ :public | :disabled | :disabled | :guest | nil | 0
+ :public | :disabled | :disabled | :non_member | nil | 0
+ :public | :disabled | :disabled | :anonymous | nil | 0
+
+ :internal | :enabled | :enabled | :admin | true | 1
+ :internal | :enabled | :enabled | :admin | false | 1
+ :internal | :enabled | :enabled | :reporter | nil | 1
+ :internal | :enabled | :enabled | :guest | nil | 1
+ :internal | :enabled | :enabled | :non_member | nil | 1
+ :internal | :enabled | :enabled | :anonymous | nil | 0
+
+ :internal | :enabled | :private | :admin | true | 1
+ :internal | :enabled | :private | :admin | false | 1
+ :internal | :enabled | :private | :reporter | nil | 1
+ :internal | :enabled | :private | :guest | nil | 1
+ :internal | :enabled | :private | :non_member | nil | 1
+ :internal | :enabled | :private | :anonymous | nil | 0
+
+ :internal | :enabled | :disabled | :admin | true | 1
+ :internal | :enabled | :disabled | :admin | false | 1
+ :internal | :enabled | :disabled | :reporter | nil | 1
+ :internal | :enabled | :disabled | :guest | nil | 1
+ :internal | :enabled | :disabled | :non_member | nil | 1
+ :internal | :enabled | :disabled | :anonymous | nil | 0
+
+ :internal | :private | :enabled | :admin | true | 1
+ :internal | :private | :enabled | :admin | false | 1
+ :internal | :private | :enabled | :reporter | nil | 1
+ :internal | :private | :enabled | :guest | nil | 1
+ :internal | :private | :enabled | :non_member | nil | 1
+ :internal | :private | :enabled | :anonymous | nil | 0
+
+ :internal | :private | :private | :admin | true | 1
+ :internal | :private | :private | :admin | false | 0
+ :internal | :private | :private | :reporter | nil | 1
+ :internal | :private | :private | :guest | nil | 1
+ :internal | :private | :private | :non_member | nil | 0
+ :internal | :private | :private | :anonymous | nil | 0
+
+ :internal | :private | :disabled | :admin | true | 1
+ :internal | :private | :disabled | :admin | false | 0
+ :internal | :private | :disabled | :reporter | nil | 1
+ :internal | :private | :disabled | :guest | nil | 1
+ :internal | :private | :disabled | :non_member | nil | 0
+ :internal | :private | :disabled | :anonymous | nil | 0
+
+ :internal | :disabled | :enabled | :admin | true | 1
+ :internal | :disabled | :enabled | :admin | false | 1
+ :internal | :disabled | :enabled | :reporter | nil | 1
+ :internal | :disabled | :enabled | :guest | nil | 1
+ :internal | :disabled | :enabled | :non_member | nil | 1
+ :internal | :disabled | :enabled | :anonymous | nil | 0
+
+ :internal | :disabled | :private | :admin | true | 1
+ :internal | :disabled | :private | :admin | false | 0
+ :internal | :disabled | :private | :reporter | nil | 1
+ :internal | :disabled | :private | :guest | nil | 0
+ :internal | :disabled | :private | :non_member | nil | 0
+ :internal | :disabled | :private | :anonymous | nil | 0
+
+ :internal | :disabled | :disabled | :reporter | nil | 0
+ :internal | :disabled | :disabled | :guest | nil | 0
+ :internal | :disabled | :disabled | :non_member | nil | 0
+ :internal | :disabled | :disabled | :anonymous | nil | 0
+
+ :private | :private | :private | :admin | true | 1
+ :private | :private | :private | :admin | false | 0
+ :private | :private | :private | :reporter | nil | 1
+ :private | :private | :private | :guest | nil | 1
+ :private | :private | :private | :non_member | nil | 0
+ :private | :private | :private | :anonymous | nil | 0
+
+ :private | :private | :disabled | :admin | true | 1
+ :private | :private | :disabled | :admin | false | 0
+ :private | :private | :disabled | :reporter | nil | 1
+ :private | :private | :disabled | :guest | nil | 1
+ :private | :private | :disabled | :non_member | nil | 0
+ :private | :private | :disabled | :anonymous | nil | 0
+
+ :private | :disabled | :private | :admin | true | 1
+ :private | :disabled | :private | :admin | false | 0
+ :private | :disabled | :private | :reporter | nil | 1
+ :private | :disabled | :private | :guest | nil | 0
+ :private | :disabled | :private | :non_member | nil | 0
+ :private | :disabled | :private | :anonymous | nil | 0
+
+ :private | :disabled | :disabled | :reporter | nil | 0
+ :private | :disabled | :disabled | :guest | nil | 0
+ :private | :disabled | :disabled | :non_member | nil | 0
+ :private | :disabled | :disabled | :anonymous | nil | 0
end
# :project_level, :membership, :expected_count
@@ -321,166 +358,192 @@ RSpec.shared_context 'ProjectPolicyTable context' do
# :snippet_level, :project_level, :feature_access_level, :membership, :expected_count
def permission_table_for_project_snippet_access
- :public | :public | :enabled | :admin | 1
- :public | :public | :enabled | :reporter | 1
- :public | :public | :enabled | :guest | 1
- :public | :public | :enabled | :non_member | 1
- :public | :public | :enabled | :anonymous | 1
-
- :public | :public | :private | :admin | 1
- :public | :public | :private | :reporter | 1
- :public | :public | :private | :guest | 1
- :public | :public | :private | :non_member | 0
- :public | :public | :private | :anonymous | 0
-
- :public | :public | :disabled | :admin | 1
- :public | :public | :disabled | :reporter | 0
- :public | :public | :disabled | :guest | 0
- :public | :public | :disabled | :non_member | 0
- :public | :public | :disabled | :anonymous | 0
-
- :public | :internal | :enabled | :admin | 1
- :public | :internal | :enabled | :reporter | 1
- :public | :internal | :enabled | :guest | 1
- :public | :internal | :enabled | :non_member | 1
- :public | :internal | :enabled | :anonymous | 0
-
- :public | :internal | :private | :admin | 1
- :public | :internal | :private | :reporter | 1
- :public | :internal | :private | :guest | 1
- :public | :internal | :private | :non_member | 0
- :public | :internal | :private | :anonymous | 0
-
- :public | :internal | :disabled | :admin | 1
- :public | :internal | :disabled | :reporter | 0
- :public | :internal | :disabled | :guest | 0
- :public | :internal | :disabled | :non_member | 0
- :public | :internal | :disabled | :anonymous | 0
-
- :public | :private | :private | :admin | 1
- :public | :private | :private | :reporter | 1
- :public | :private | :private | :guest | 1
- :public | :private | :private | :non_member | 0
- :public | :private | :private | :anonymous | 0
-
- :public | :private | :disabled | :reporter | 0
- :public | :private | :disabled | :guest | 0
- :public | :private | :disabled | :non_member | 0
- :public | :private | :disabled | :anonymous | 0
-
- :internal | :public | :enabled | :admin | 1
- :internal | :public | :enabled | :reporter | 1
- :internal | :public | :enabled | :guest | 1
- :internal | :public | :enabled | :non_member | 1
- :internal | :public | :enabled | :anonymous | 0
-
- :internal | :public | :private | :admin | 1
- :internal | :public | :private | :reporter | 1
- :internal | :public | :private | :guest | 1
- :internal | :public | :private | :non_member | 0
- :internal | :public | :private | :anonymous | 0
-
- :internal | :public | :disabled | :admin | 1
- :internal | :public | :disabled | :reporter | 0
- :internal | :public | :disabled | :guest | 0
- :internal | :public | :disabled | :non_member | 0
- :internal | :public | :disabled | :anonymous | 0
-
- :internal | :internal | :enabled | :admin | 1
- :internal | :internal | :enabled | :reporter | 1
- :internal | :internal | :enabled | :guest | 1
- :internal | :internal | :enabled | :non_member | 1
- :internal | :internal | :enabled | :anonymous | 0
-
- :internal | :internal | :private | :admin | 1
- :internal | :internal | :private | :reporter | 1
- :internal | :internal | :private | :guest | 1
- :internal | :internal | :private | :non_member | 0
- :internal | :internal | :private | :anonymous | 0
-
- :internal | :internal | :disabled | :admin | 1
- :internal | :internal | :disabled | :reporter | 0
- :internal | :internal | :disabled | :guest | 0
- :internal | :internal | :disabled | :non_member | 0
- :internal | :internal | :disabled | :anonymous | 0
-
- :internal | :private | :private | :admin | 1
- :internal | :private | :private | :reporter | 1
- :internal | :private | :private | :guest | 1
- :internal | :private | :private | :non_member | 0
- :internal | :private | :private | :anonymous | 0
-
- :internal | :private | :disabled | :admin | 1
- :internal | :private | :disabled | :reporter | 0
- :internal | :private | :disabled | :guest | 0
- :internal | :private | :disabled | :non_member | 0
- :internal | :private | :disabled | :anonymous | 0
-
- :private | :public | :enabled | :admin | 1
- :private | :public | :enabled | :reporter | 1
- :private | :public | :enabled | :guest | 1
- :private | :public | :enabled | :non_member | 0
- :private | :public | :enabled | :anonymous | 0
-
- :private | :public | :private | :admin | 1
- :private | :public | :private | :reporter | 1
- :private | :public | :private | :guest | 1
- :private | :public | :private | :non_member | 0
- :private | :public | :private | :anonymous | 0
-
- :private | :public | :disabled | :admin | 1
- :private | :public | :disabled | :reporter | 0
- :private | :public | :disabled | :guest | 0
- :private | :public | :disabled | :non_member | 0
- :private | :public | :disabled | :anonymous | 0
-
- :private | :internal | :enabled | :admin | 1
- :private | :internal | :enabled | :reporter | 1
- :private | :internal | :enabled | :guest | 1
- :private | :internal | :enabled | :non_member | 0
- :private | :internal | :enabled | :anonymous | 0
-
- :private | :internal | :private | :admin | 1
- :private | :internal | :private | :reporter | 1
- :private | :internal | :private | :guest | 1
- :private | :internal | :private | :non_member | 0
- :private | :internal | :private | :anonymous | 0
-
- :private | :internal | :disabled | :admin | 1
- :private | :internal | :disabled | :reporter | 0
- :private | :internal | :disabled | :guest | 0
- :private | :internal | :disabled | :non_member | 0
- :private | :internal | :disabled | :anonymous | 0
-
- :private | :private | :private | :admin | 1
- :private | :private | :private | :reporter | 1
- :private | :private | :private | :guest | 1
- :private | :private | :private | :non_member | 0
- :private | :private | :private | :anonymous | 0
-
- :private | :private | :disabled | :admin | 1
- :private | :private | :disabled | :reporter | 0
- :private | :private | :disabled | :guest | 0
- :private | :private | :disabled | :non_member | 0
- :private | :private | :disabled | :anonymous | 0
+ :public | :public | :enabled | :admin | true | 1
+ :public | :public | :enabled | :admin | false | 1
+ :public | :public | :enabled | :reporter | nil | 1
+ :public | :public | :enabled | :guest | nil | 1
+ :public | :public | :enabled | :non_member | nil | 1
+ :public | :public | :enabled | :anonymous | nil | 1
+
+ :public | :public | :private | :admin | true | 1
+ :public | :public | :private | :admin | false | 0
+ :public | :public | :private | :reporter | nil | 1
+ :public | :public | :private | :guest | nil | 1
+ :public | :public | :private | :non_member | nil | 0
+ :public | :public | :private | :anonymous | nil | 0
+
+ :public | :public | :disabled | :admin | true | 1
+ :public | :public | :disabled | :admin | false | 0
+ :public | :public | :disabled | :reporter | nil | 0
+ :public | :public | :disabled | :guest | nil | 0
+ :public | :public | :disabled | :non_member | nil | 0
+ :public | :public | :disabled | :anonymous | nil | 0
+
+ :public | :internal | :enabled | :admin | true | 1
+ :public | :internal | :enabled | :admin | false | 1
+ :public | :internal | :enabled | :reporter | nil | 1
+ :public | :internal | :enabled | :guest | nil | 1
+ :public | :internal | :enabled | :non_member | nil | 1
+ :public | :internal | :enabled | :anonymous | nil | 0
+
+ :public | :internal | :private | :admin | true | 1
+ :public | :internal | :private | :admin | false | 0
+ :public | :internal | :private | :reporter | nil | 1
+ :public | :internal | :private | :guest | nil | 1
+ :public | :internal | :private | :non_member | nil | 0
+ :public | :internal | :private | :anonymous | nil | 0
+
+ :public | :internal | :disabled | :admin | true | 1
+ :public | :internal | :disabled | :admin | false | 0
+ :public | :internal | :disabled | :reporter | nil | 0
+ :public | :internal | :disabled | :guest | nil | 0
+ :public | :internal | :disabled | :non_member | nil | 0
+ :public | :internal | :disabled | :anonymous | nil | 0
+
+ :public | :private | :private | :admin | true | 1
+ :public | :private | :private | :admin | false | 0
+ :public | :private | :private | :reporter | nil | 1
+ :public | :private | :private | :guest | nil | 1
+ :public | :private | :private | :non_member | nil | 0
+ :public | :private | :private | :anonymous | nil | 0
+
+ :public | :private | :disabled | :reporter | nil | 0
+ :public | :private | :disabled | :guest | nil | 0
+ :public | :private | :disabled | :non_member | nil | 0
+ :public | :private | :disabled | :anonymous | nil | 0
+
+ :internal | :public | :enabled | :admin | true | 1
+ :internal | :public | :enabled | :admin | false | 1
+ :internal | :public | :enabled | :reporter | nil | 1
+ :internal | :public | :enabled | :guest | nil | 1
+ :internal | :public | :enabled | :non_member | nil | 1
+ :internal | :public | :enabled | :anonymous | nil | 0
+
+ :internal | :public | :private | :admin | true | 1
+ :internal | :public | :private | :admin | false | 0
+ :internal | :public | :private | :reporter | nil | 1
+ :internal | :public | :private | :guest | nil | 1
+ :internal | :public | :private | :non_member | nil | 0
+ :internal | :public | :private | :anonymous | nil | 0
+
+ :internal | :public | :disabled | :admin | true | 1
+ :internal | :public | :disabled | :admin | false | 0
+ :internal | :public | :disabled | :reporter | nil | 0
+ :internal | :public | :disabled | :guest | nil | 0
+ :internal | :public | :disabled | :non_member | nil | 0
+ :internal | :public | :disabled | :anonymous | nil | 0
+
+ :internal | :internal | :enabled | :admin | true | 1
+ :internal | :internal | :enabled | :admin | false | 1
+ :internal | :internal | :enabled | :reporter | nil | 1
+ :internal | :internal | :enabled | :guest | nil | 1
+ :internal | :internal | :enabled | :non_member | nil | 1
+ :internal | :internal | :enabled | :anonymous | nil | 0
+
+ :internal | :internal | :private | :admin | true | 1
+ :internal | :internal | :private | :admin | false | 0
+ :internal | :internal | :private | :reporter | nil | 1
+ :internal | :internal | :private | :guest | nil | 1
+ :internal | :internal | :private | :non_member | nil | 0
+ :internal | :internal | :private | :anonymous | nil | 0
+
+ :internal | :internal | :disabled | :admin | true | 1
+ :internal | :internal | :disabled | :admin | false | 0
+ :internal | :internal | :disabled | :reporter | nil | 0
+ :internal | :internal | :disabled | :guest | nil | 0
+ :internal | :internal | :disabled | :non_member | nil | 0
+ :internal | :internal | :disabled | :anonymous | nil | 0
+
+ :internal | :private | :private | :admin | true | 1
+ :internal | :private | :private | :admin | false | 0
+ :internal | :private | :private | :reporter | nil | 1
+ :internal | :private | :private | :guest | nil | 1
+ :internal | :private | :private | :non_member | nil | 0
+ :internal | :private | :private | :anonymous | nil | 0
+
+ :internal | :private | :disabled | :admin | true | 1
+ :internal | :private | :disabled | :admin | false | 0
+ :internal | :private | :disabled | :reporter | nil | 0
+ :internal | :private | :disabled | :guest | nil | 0
+ :internal | :private | :disabled | :non_member | nil | 0
+ :internal | :private | :disabled | :anonymous | nil | 0
+
+ :private | :public | :enabled | :admin | true | 1
+ :private | :public | :enabled | :admin | false | 0
+ :private | :public | :enabled | :reporter | nil | 1
+ :private | :public | :enabled | :guest | nil | 1
+ :private | :public | :enabled | :non_member | nil | 0
+ :private | :public | :enabled | :anonymous | nil | 0
+
+ :private | :public | :private | :admin | true | 1
+ :private | :public | :private | :admin | false | 0
+ :private | :public | :private | :reporter | nil | 1
+ :private | :public | :private | :guest | nil | 1
+ :private | :public | :private | :non_member | nil | 0
+ :private | :public | :private | :anonymous | nil | 0
+
+ :private | :public | :disabled | :admin | true | 1
+ :private | :public | :disabled | :admin | false | 0
+ :private | :public | :disabled | :reporter | nil | 0
+ :private | :public | :disabled | :guest | nil | 0
+ :private | :public | :disabled | :non_member | nil | 0
+ :private | :public | :disabled | :anonymous | nil | 0
+
+ :private | :internal | :enabled | :admin | true | 1
+ :private | :internal | :enabled | :admin | false | 0
+ :private | :internal | :enabled | :reporter | nil | 1
+ :private | :internal | :enabled | :guest | nil | 1
+ :private | :internal | :enabled | :non_member | nil | 0
+ :private | :internal | :enabled | :anonymous | nil | 0
+
+ :private | :internal | :private | :admin | true | 1
+ :private | :internal | :private | :admin | false | 0
+ :private | :internal | :private | :reporter | nil | 1
+ :private | :internal | :private | :guest | nil | 1
+ :private | :internal | :private | :non_member | nil | 0
+ :private | :internal | :private | :anonymous | nil | 0
+
+ :private | :internal | :disabled | :admin | true | 1
+ :private | :internal | :disabled | :admin | false | 0
+ :private | :internal | :disabled | :reporter | nil | 0
+ :private | :internal | :disabled | :guest | nil | 0
+ :private | :internal | :disabled | :non_member | nil | 0
+ :private | :internal | :disabled | :anonymous | nil | 0
+
+ :private | :private | :private | :admin | true | 1
+ :private | :private | :private | :admin | false | 0
+ :private | :private | :private | :reporter | nil | 1
+ :private | :private | :private | :guest | nil | 1
+ :private | :private | :private | :non_member | nil | 0
+ :private | :private | :private | :anonymous | nil | 0
+
+ :private | :private | :disabled | :admin | true | 1
+ :private | :private | :disabled | :admin | false | 0
+ :private | :private | :disabled | :reporter | nil | 0
+ :private | :private | :disabled | :guest | nil | 0
+ :private | :private | :disabled | :non_member | nil | 0
+ :private | :private | :disabled | :anonymous | nil | 0
end
# :snippet_level, :membership, :expected_count
def permission_table_for_personal_snippet_access
- :public | :admin | 1
- :public | :author | 1
- :public | :non_member | 1
- :public | :anonymous | 1
-
- :internal | :admin | 1
- :internal | :author | 1
- :internal | :non_member | 1
- :internal | :anonymous | 0
-
- :private | :admin | 1
- :private | :author | 1
- :private | :non_member | 0
- :private | :anonymous | 0
+ :public | :admin | true | 1
+ :public | :admin | false | 1
+ :public | :author | nil | 1
+ :public | :non_member | nil | 1
+ :public | :anonymous | nil | 1
+
+ :internal | :admin | true | 1
+ :internal | :admin | false | 1
+ :internal | :author | nil | 1
+ :internal | :non_member | nil | 1
+ :internal | :anonymous | nil | 0
+
+ :private | :admin | true | 1
+ :private | :admin | false | 0
+ :private | :author | nil | 1
+ :private | :non_member | nil | 0
+ :private | :anonymous | nil | 0
end
# rubocop:enable Metrics/AbcSize
end
diff --git a/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb b/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb
index edc5b313220..de40b926a1c 100644
--- a/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb
+++ b/spec/support/shared_contexts/requests/api/graphql/jira_import/jira_projects_context.rb
@@ -116,6 +116,7 @@ RSpec.shared_context 'Jira projects request context' do
"uuid": "14935009-f8aa-481e-94bc-f7251f320b0e"
}]'
end
+
let_it_be(:empty_jira_projects_json) do
'{
"self": "https://your-domain.atlassian.net/rest/api/2/project/search?startAt=0&maxResults=2",
diff --git a/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb
new file mode 100644
index 00000000000..7c23ec33cf8
--- /dev/null
+++ b/spec/support/shared_contexts/requests/api/npm_packages_shared_context.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+RSpec.shared_context 'npm api setup' do
+ include PackagesManagerApiSpecHelpers
+ include HttpBasicAuthHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project, reload: true) { create(:project, :public, namespace: group) }
+ let_it_be(:package, reload: true) { create(:npm_package, project: project) }
+ let_it_be(:token) { create(:oauth_access_token, scopes: 'api', resource_owner: user) }
+ let_it_be(:personal_access_token) { create(:personal_access_token, user: user) }
+ let_it_be(:job, reload: true) { create(:ci_build, user: user, status: :running) }
+ let_it_be(:deploy_token) { create(:deploy_token, read_package_registry: true, write_package_registry: true) }
+ let_it_be(:project_deploy_token) { create(:project_deploy_token, deploy_token: deploy_token, project: project) }
+
+ let(:package_name) { package.name }
+
+ before do
+ project.add_developer(user)
+ end
+end
diff --git a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
index 84910d0dfe4..38a5ed244c4 100644
--- a/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
+++ b/spec/support/shared_examples/boards/multiple_issue_boards_shared_examples.rb
@@ -48,7 +48,7 @@ RSpec.shared_examples 'multiple issue boards' do
expect(page).to have_button('This is a new board')
end
- it 'deletes board' do
+ it 'deletes board', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/280554' do
in_boards_switcher_dropdown do
click_button 'Delete board'
end
diff --git a/spec/support/shared_examples/cached_response_shared_examples.rb b/spec/support/shared_examples/cached_response_shared_examples.rb
deleted file mode 100644
index 34e5f741b4e..00000000000
--- a/spec/support/shared_examples/cached_response_shared_examples.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-# frozen_string_literal: true
-#
-# Negates lib/gitlab/no_cache_headers.rb
-#
-
-RSpec.shared_examples 'cached response' do
- it 'defines a cached header response' do
- expect(response.headers["Cache-Control"]).not_to include("no-store", "no-cache")
- expect(response.headers["Pragma"]).not_to eq("no-cache")
- expect(response.headers["Expires"]).not_to eq("Fri, 01 Jan 1990 00:00:00 GMT")
- end
-end
diff --git a/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb b/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb
index 54d41f9a68c..dd71107455f 100644
--- a/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/access_tokens_controller_shared_examples.rb
@@ -73,7 +73,23 @@ RSpec.shared_examples 'project access tokens available #create' do
end
end
- it { expect(subject).to render_template(:index) }
+ it 'does not create the token' do
+ expect { subject }.not_to change { PersonalAccessToken.count }
+ end
+
+ it 'does not add the project bot as a member' do
+ expect { subject }.not_to change { Member.count }
+ end
+
+ it 'does not create the project bot user' do
+ expect { subject }.not_to change { User.count }
+ end
+
+ it 'shows a failure alert' do
+ subject
+
+ expect(response.flash[:alert]).to match("Failed to create new project access token: Failed!")
+ end
end
end
diff --git a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
index 2fcc88ef36a..5a4322f73b6 100644
--- a/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/githubish_import_controller_shared_examples.rb
@@ -145,6 +145,8 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
group.add_owner(user)
client = stub_client(repos: repos, orgs: [org], org_repos: [org_repo])
allow(client).to receive(:each_page).and_return([OpenStruct.new(objects: repos)].to_enum)
+ # GitHub controller has filtering done using GitHub Search API
+ stub_feature_flags(remove_legacy_github_client: false)
end
it 'filters list of repositories by name' do
@@ -157,6 +159,16 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
end
+ it 'filters the list, ignoring the case of the name' do
+ get :status, params: { filter: 'EMACS' }, format: :json
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(json_response.dig("imported_projects").count).to eq(0)
+ expect(json_response.dig("provider_repos").count).to eq(1)
+ expect(json_response.dig("provider_repos", 0, "id")).to eq(repo_2.id)
+ expect(json_response.dig("namespaces", 0, "id")).to eq(group.id)
+ end
+
context 'when user input contains html' do
let(:expected_filter) { 'test' }
let(:filter) { "<html>#{expected_filter}</html>" }
@@ -167,6 +179,23 @@ RSpec.shared_examples 'a GitHub-ish import controller: GET status' do
expect(assigns(:filter)).to eq(expected_filter)
end
end
+
+ context 'when the client returns a non-string name' do
+ before do
+ repos = [build(:project, name: 2, path: 'test')]
+
+ client = stub_client(repos: repos)
+ allow(client).to receive(:each_page).and_return([OpenStruct.new(objects: repos)].to_enum)
+ end
+
+ it 'does not raise an error' do
+ get :status, params: { filter: '2' }, format: :json
+
+ expect(response).to have_gitlab_http_status :ok
+
+ expect(json_response.dig("provider_repos").count).to eq(1)
+ end
+ end
end
end
diff --git a/spec/support/shared_examples/controllers/trackable_shared_examples.rb b/spec/support/shared_examples/controllers/trackable_shared_examples.rb
index e82c27c43f5..dac7d8c94ff 100644
--- a/spec/support/shared_examples/controllers/trackable_shared_examples.rb
+++ b/spec/support/shared_examples/controllers/trackable_shared_examples.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
RSpec.shared_examples 'a Trackable Controller' do
- describe '#track_event' do
+ describe '#track_event', :snowplow do
before do
sign_in user
end
@@ -14,9 +14,10 @@ RSpec.shared_examples 'a Trackable Controller' do
end
end
- it 'tracks the action name' do
- expect(Gitlab::Tracking).to receive(:event).with('AnonymousController', 'index', {})
+ it 'tracks the action name', :snowplow do
get :index
+
+ expect_snowplow_event(category: 'AnonymousController', action: 'index')
end
end
@@ -29,8 +30,9 @@ RSpec.shared_examples 'a Trackable Controller' do
end
it 'tracks with the specified param' do
- expect(Gitlab::Tracking).to receive(:event).with('SomeCategory', 'some_event', label: 'errorlabel')
get :index
+
+ expect_snowplow_event(category: 'SomeCategory', action: 'some_event', label: 'errorlabel')
end
end
end
diff --git a/spec/support/shared_examples/features/discussion_comments_shared_example.rb b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
index 9fc5d8933e5..560cfbfb117 100644
--- a/spec/support/shared_examples/features/discussion_comments_shared_example.rb
+++ b/spec/support/shared_examples/features/discussion_comments_shared_example.rb
@@ -56,12 +56,12 @@ RSpec.shared_examples 'thread comments' do |resource_name|
expect(items.first).to have_content 'Comment'
expect(items.first).to have_content "Add a general comment to this #{resource_name}."
- expect(items.first).to have_selector '.fa-check'
+ expect(items.first).to have_selector '[data-testid="check-icon"]'
expect(items.first['class']).to match 'droplab-item-selected'
expect(items.last).to have_content 'Start thread'
expect(items.last).to have_content "Discuss a specific suggestion or question#{' that needs to be resolved' if resource_name == 'merge request'}."
- expect(items.last).not_to have_selector '.fa-check'
+ expect(items.last).not_to have_selector '[data-testid="check-icon"]'
expect(items.last['class']).not_to match 'droplab-item-selected'
end
@@ -228,11 +228,11 @@ RSpec.shared_examples 'thread comments' do |resource_name|
items = all("#{menu_selector} li")
expect(items.first).to have_content 'Comment'
- expect(items.first).not_to have_selector '.fa-check'
+ expect(items.first).not_to have_selector '[data-testid="check-icon"]'
expect(items.first['class']).not_to match 'droplab-item-selected'
expect(items.last).to have_content 'Start thread'
- expect(items.last).to have_selector '.fa-check'
+ expect(items.last).to have_selector '[data-testid="check-icon"]'
expect(items.last['class']).to match 'droplab-item-selected'
end
@@ -274,11 +274,11 @@ RSpec.shared_examples 'thread comments' do |resource_name|
aggregate_failures do
expect(items.first).to have_content 'Comment'
- expect(items.first).to have_selector '.fa-check'
+ expect(items.first).to have_selector '[data-testid="check-icon"]'
expect(items.first['class']).to match 'droplab-item-selected'
expect(items.last).to have_content 'Start thread'
- expect(items.last).not_to have_selector '.fa-check'
+ expect(items.last).not_to have_selector '[data-testid="check-icon"]'
expect(items.last['class']).not_to match 'droplab-item-selected'
end
end
diff --git a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
index ffe4fb83283..724d6db2705 100644
--- a/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
+++ b/spec/support/shared_examples/features/master_manages_access_requests_shared_example.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
RSpec.shared_examples 'Maintainer manages access requests' do
+ include Spec::Support::Helpers::Features::MembersHelpers
+
let(:user) { create(:user) }
let(:maintainer) { create(:user) }
@@ -26,7 +28,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do
expect_no_visible_access_request(entity, user)
- page.within('[data-qa-selector="members_list"]') do
+ page.within(members_table) do
expect(page).to have_content user.name
end
end
@@ -35,7 +37,7 @@ RSpec.shared_examples 'Maintainer manages access requests' do
expect_visible_access_request(entity, user)
# Open modal
- click_on 'Deny access request'
+ click_on 'Deny access'
expect(page).not_to have_field "Also unassign this user from related issues and merge requests"
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 218ef070221..e0d169c6868 100644
--- a/spec/support/shared_examples/features/variable_list_shared_examples.rb
+++ b/spec/support/shared_examples/features/variable_list_shared_examples.rb
@@ -1,387 +1,295 @@
# frozen_string_literal: true
RSpec.shared_examples 'variable list' do
- it 'shows list of variables' do
- page.within('.js-ci-variable-list-section') do
- expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
+ it 'shows a list of variables' do
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq(variable.key)
end
end
- it 'adds new CI variable' 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')
+ it 'adds a new CI variable' do
+ click_button('Add Variable')
+
+ fill_variable('key', 'key_value') do
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
-
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
end
end
it 'adds a new protected variable' 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')
+ click_button('Add Variable')
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ fill_variable('key', 'key_value') do
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
-
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Protected"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
end
end
it 'defaults to unmasked' 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')
+ click_button('Add Variable')
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
+ fill_variable('key', 'key_value') do
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
-
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('key_value')
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
- end
- end
-
- context 'defaults to the application setting' do
- context 'application setting is true' do
- before do
- stub_application_setting(protected_ci_variables: true)
-
- visit page_path
- end
-
- it 'defaults to protected' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('key')
- end
-
- values = all('.js-ci-variable-input-protected', visible: false).map(&:value)
-
- expect(values).to eq %w(false true true)
- end
-
- it 'shows a message regarding the changed default' do
- expect(page).to have_content 'Environment variables are configured by your administrator to be protected by default'
- end
- end
-
- context 'application setting is false' do
- before do
- stub_application_setting(protected_ci_variables: false)
-
- visit page_path
- end
-
- it 'defaults to unprotected' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('key')
- end
-
- values = all('.js-ci-variable-input-protected', visible: false).map(&:value)
-
- expect(values).to eq %w(false false false)
- end
-
- it 'does not show a message regarding the default' do
- expect(page).not_to have_content 'Environment variables are configured by your administrator to be protected by default'
- end
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Key"]').text).to eq('key')
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
it 'reveals and hides variables' do
- page.within('.js-ci-variable-list-section') do
- expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
- expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
+ page.within('.ci-variable-table') do
+ expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
click_button('Reveal value')
- expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
- expect(first('.js-ci-variable-input-value').value).to eq(variable.value)
+ expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
+ expect(first('.js-ci-variable-row td[data-label="Value"]').text).to eq(variable.value)
expect(page).not_to have_content('*' * 17)
click_button('Hide value')
- expect(first('.js-ci-variable-input-key').value).to eq(variable.key)
- expect(first('.js-ci-variable-input-value', visible: false).value).to eq(variable.value)
+ expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq(variable.key)
expect(page).to have_content('*' * 17)
end
end
- it 'deletes variable' do
- page.within('.js-ci-variable-list-section') do
- expect(page).to have_selector('.js-row', count: 2)
+ it 'deletes a variable' do
+ expect(page).to have_selector('.js-ci-variable-row', count: 1)
- first('.js-row-remove-button').click
-
- click_button('Save variables')
- wait_for_requests
-
- expect(page).to have_selector('.js-row', count: 1)
+ page.within('.ci-variable-table') do
+ click_button('Edit')
end
- end
- it 'edits variable' do
- page.within('.js-ci-variable-list-section') do
- click_button('Reveal value')
-
- page.within('.js-row:nth-child(2)') do
- find('.js-ci-variable-input-key').set('new_key')
- find('.js-ci-variable-input-value').set('new_value')
- end
+ page.within('#add-ci-variable') do
+ click_button('Delete variable')
+ end
- click_button('Save variables')
- wait_for_requests
+ wait_for_requests
- visit page_path
+ expect(first('.js-ci-variable-row').text).to eq('There are no variables yet.')
+ end
- page.within('.js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('new_key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('new_value')
- end
+ it 'edits a variable' do
+ page.within('.ci-variable-table') do
+ click_button('Edit')
end
- end
- it 'edits variable to be protected' do
- # Create the unprotected variable
- 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
+ page.within('#add-ci-variable') do
+ find('[data-qa-selector="ci_variable_key_field"] input').set('new_key')
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ click_button('Update variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
+ expect(first('.js-ci-variable-row td[data-label="Key"]').text).to eq('new_key')
+ end
+
+ it 'edits a variable to be unmasked' do
+ page.within('.ci-variable-table') do
+ click_button('Edit')
+ end
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section .js-row:nth-child(3)') do
- find('.ci-variable-protected-item .js-project-feature-toggle').click
+ page.within('#add-ci-variable') do
+ find('[data-testid="ci-variable-protected-checkbox"]').click
+ find('[data-testid="ci-variable-masked-checkbox"]').click
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ click_button('Update variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
-
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section .js-row:nth-child(3)') do
- expect(find('.js-ci-variable-input-key').value).to eq('unprotected_key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('unprotected_value')
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="close-icon"]')).to be_present
end
end
- it 'edits variable to be unprotected' do
- # Create the protected variable
- 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')
+ it 'edits a variable to be masked' do
+ page.within('.ci-variable-table') do
+ click_button('Edit')
+ end
+
+ page.within('#add-ci-variable') do
+ find('[data-testid="ci-variable-masked-checkbox"]').click
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('true')
+ click_button('Update variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
+ page.within('.ci-variable-table') do
+ click_button('Edit')
+ end
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- find('.ci-variable-protected-item .js-project-feature-toggle').click
+ page.within('#add-ci-variable') do
+ find('[data-testid="ci-variable-masked-checkbox"]').click
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ click_button('Update variable')
end
- click_button('Save variables')
- wait_for_requests
+ page.within('.ci-variable-table') do
+ expect(find('.js-ci-variable-row:nth-child(1) td[data-label="Masked"] svg[data-testid="mobile-issue-close-icon"]')).to be_present
+ end
+ end
- visit page_path
+ it 'shows a validation error box about duplicate keys' do
+ click_button('Add Variable')
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-key').value).to eq('protected_key')
- expect(find('.js-ci-variable-input-value', visible: false).value).to eq('protected_value')
- expect(find('.js-ci-variable-input-protected', visible: false).value).to eq('false')
+ fill_variable('key', 'key_value') do
+ click_button('Add variable')
end
- end
- it 'edits variable to be unmasked' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('unmasked_key')
- find('.js-ci-variable-input-value').set('unmasked_value')
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
+ wait_for_requests
- find('.ci-variable-masked-item .js-project-feature-toggle').click
+ click_button('Add Variable')
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
+ fill_variable('key', 'key_value') do
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
+ expect(find('.flash-container')).to be_present
+ expect(find('.flash-text').text).to have_content('Variables key (key) has already been taken')
+ end
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
+ it 'prevents a variable to be added if no values are provided when a variable is set to masked' do
+ click_button('Add Variable')
- find('.ci-variable-masked-item .js-project-feature-toggle').click
+ page.within('#add-ci-variable') do
+ find('[data-qa-selector="ci_variable_key_field"] input').set('empty_mask_key')
+ find('[data-testid="ci-variable-protected-checkbox"]').click
+ find('[data-testid="ci-variable-masked-checkbox"]').click
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
+ expect(find_button('Add variable', disabled: true)).to be_present
end
+ end
- click_button('Save variables')
- wait_for_requests
-
- visit page_path
+ it 'shows validation error box about unmaskable values' do
+ click_button('Add Variable')
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
+ fill_variable('empty_mask_key', '???', protected: true, masked: true) do
+ expect(page).to have_content('This variable can not be masked')
+ expect(find_button('Add variable', disabled: true)).to be_present
end
end
- it 'edits variable to be masked' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('masked_key')
- find('.js-ci-variable-input-value').set('masked_value')
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('false')
+ it 'handles multiple edits and a deletion' do
+ # Create two variables
+ click_button('Add Variable')
- find('.ci-variable-masked-item .js-project-feature-toggle').click
-
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
+ fill_variable('akey', 'akeyvalue') do
+ click_button('Add variable')
end
- click_button('Save variables')
wait_for_requests
- visit page_path
+ click_button('Add Variable')
- page.within('.js-ci-variable-list-section .js-row:nth-child(2)') do
- expect(find('.js-ci-variable-input-masked', visible: false).value).to eq('true')
+ fill_variable('zkey', 'zkeyvalue') do
+ click_button('Add variable')
end
- end
-
- it 'handles multiple edits and deletion in the middle' do
- page.within('.js-ci-variable-list-section') do
- # Create 2 variables
- page.within('.js-row:last-child') do
- find('.js-ci-variable-input-key').set('akey')
- find('.js-ci-variable-input-value').set('akeyvalue')
- end
- page.within('.js-row:last-child') do
- find('.js-ci-variable-input-key').set('zkey')
- find('.js-ci-variable-input-value').set('zkeyvalue')
- end
- click_button('Save variables')
- wait_for_requests
+ wait_for_requests
- expect(page).to have_selector('.js-row', count: 4)
+ expect(page).to have_selector('.js-ci-variable-row', count: 3)
- # Remove the `akey` variable
- page.within('.js-row:nth-child(3)') do
- first('.js-row-remove-button').click
+ # Remove the `akey` variable
+ page.within('.ci-variable-table') do
+ page.within('.js-ci-variable-row:first-child') do
+ click_button('Edit')
end
+ end
- # Add another variable
- page.within('.js-row:last-child') do
- find('.js-ci-variable-input-key').set('ckey')
- find('.js-ci-variable-input-value').set('ckeyvalue')
- end
+ page.within('#add-ci-variable') do
+ click_button('Delete variable')
+ end
- click_button('Save variables')
- wait_for_requests
+ wait_for_requests
- visit page_path
+ # Add another variable
+ click_button('Add Variable')
- # Expect to find 3 variables(4 rows) in alphbetical order
- expect(page).to have_selector('.js-row', count: 4)
- row_keys = all('.js-ci-variable-input-key')
- expect(row_keys[0].value).to eq('ckey')
- expect(row_keys[1].value).to eq('test_key')
- expect(row_keys[2].value).to eq('zkey')
- expect(row_keys[3].value).to eq('')
+ fill_variable('ckey', 'ckeyvalue') do
+ click_button('Add variable')
end
+
+ wait_for_requests
+
+ # expect to find 3 rows of variables in alphabetical order
+ expect(page).to have_selector('.js-ci-variable-row', count: 3)
+ rows = all('.js-ci-variable-row')
+ expect(rows[0].find('td[data-label="Key"]').text).to eq('ckey')
+ expect(rows[1].find('td[data-label="Key"]').text).to eq('test_key')
+ expect(rows[2].find('td[data-label="Key"]').text).to eq('zkey')
end
- it 'shows validation error box about duplicate keys' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('samekey')
- find('.js-ci-variable-input-value').set('value123')
- end
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('samekey')
- find('.js-ci-variable-input-value').set('value456')
- end
+ context 'defaults to the application setting' do
+ context 'application setting is true' do
+ before do
+ stub_application_setting(protected_ci_variables: true)
- click_button('Save variables')
- wait_for_requests
+ visit page_path
+ end
- expect(all('.js-ci-variable-list-section .js-ci-variable-error-box ul li').count).to eq(1)
+ it 'defaults to protected' do
+ click_button('Add Variable')
- # We check the first row because it re-sorts to alphabetical order on refresh
- page.within('.js-ci-variable-list-section') do
- expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables have duplicate values \(.+\)/)
- end
- end
+ page.within('#add-ci-variable') do
+ expect(find('[data-testid="ci-variable-protected-checkbox"]')).to be_checked
+ end
+ end
- it 'shows validation error box about masking empty values' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('empty_value')
- find('.js-ci-variable-input-value').set('')
- find('.ci-variable-masked-item .js-project-feature-toggle').click
+ it 'shows a message regarding the changed default' do
+ expect(page).to have_content 'Environment variables are configured by your administrator to be protected by default'
+ end
end
- click_button('Save variables')
- wait_for_requests
+ context 'application setting is false' do
+ before do
+ stub_application_setting(protected_ci_variables: false)
- page.within('.js-ci-variable-list-section') do
- expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
- expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
- end
- end
+ visit page_path
+ end
- it 'shows validation error box about unmaskable values' do
- page.within('.js-ci-variable-list-section .js-row:last-child') do
- find('.js-ci-variable-input-key').set('unmaskable_value')
- find('.js-ci-variable-input-value').set('???')
- find('.ci-variable-masked-item .js-project-feature-toggle').click
+ it 'defaults to unprotected' do
+ click_button('Add Variable')
+
+ page.within('#add-ci-variable') do
+ expect(find('[data-testid="ci-variable-protected-checkbox"]')).not_to be_checked
+ end
+ end
+
+ it 'does not show a message regarding the default' do
+ expect(page).not_to have_content 'Environment variables are configured by your administrator to be protected by default'
+ end
end
+ end
- click_button('Save variables')
- wait_for_requests
+ def fill_variable(key, value, protected: false, masked: false)
+ page.within('#add-ci-variable') do
+ find('[data-qa-selector="ci_variable_key_field"] input').set(key)
+ find('[data-qa-selector="ci_variable_value_field"]').set(value) if value.present?
+ find('[data-testid="ci-variable-protected-checkbox"]').click if protected
+ find('[data-testid="ci-variable-masked-checkbox"]').click if masked
- page.within('.js-ci-variable-list-section') do
- expect(all('.js-ci-variable-error-box ul li').count).to eq(1)
- expect(find('.js-ci-variable-error-box')).to have_content(/Validation failed Variables value is invalid/)
+ yield
end
end
end
diff --git a/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb
index e1fd9c8dbec..ee0261771f9 100644
--- a/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_deletes_wiki_page_shared_examples.rb
@@ -17,8 +17,8 @@ RSpec.shared_examples 'User deletes wiki page' do
it 'deletes a page', :js do
click_on('Edit')
click_on('Delete')
- find('.modal-footer .btn-danger').click
+ find('[data-testid="confirm_deletion_button"]').click
- expect(page).to have_content('Page was successfully deleted')
+ expect(page).to have_content('Wiki page was successfully deleted.')
end
end
diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
index 1a5f8d7d8df..3350e54a8a7 100644
--- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
@@ -213,11 +213,11 @@ RSpec.shared_examples 'User updates wiki page' do
visit wiki_page_path(wiki_page.wiki, wiki_page, action: :edit)
end
- it 'allows changing the title if the content does not change' do
+ it 'allows changing the title if the content does not change', :js do
fill_in 'Title', with: 'new title'
click_on 'Save changes'
- expect(page).to have_content('Wiki was successfully updated.')
+ expect(page).to have_content('Wiki page was successfully updated.')
end
it 'shows a validation error when trying to change the content' do
diff --git a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
index 85eedbf4cc5..af769be6d4b 100644
--- a/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_views_wiki_page_shared_examples.rb
@@ -33,7 +33,7 @@ RSpec.shared_examples 'User views a wiki page' do
click_on('Create page')
end
- expect(page).to have_content('Wiki was successfully updated.')
+ expect(page).to have_content('Wiki page was successfully created.')
end
it 'shows the history of a page that has a path' do
@@ -49,7 +49,7 @@ RSpec.shared_examples 'User views a wiki page' do
end
end
- it 'shows an old version of a page' do
+ it 'shows an old version of a page', :js do
expect(current_path).to include('one/two/three-test')
expect(find('.wiki-pages')).to have_content('three')
@@ -65,7 +65,7 @@ RSpec.shared_examples 'User views a wiki page' do
fill_in('Content', with: 'Updated Wiki Content')
click_on('Save changes')
- expect(page).to have_content('Wiki was successfully updated.')
+ expect(page).to have_content('Wiki page was successfully updated.')
click_on('Page history')
diff --git a/spec/support/shared_examples/finders/security/jobs_finder_shared_examples.rb b/spec/support/shared_examples/finders/security/jobs_finder_shared_examples.rb
new file mode 100644
index 00000000000..a332b213866
--- /dev/null
+++ b/spec/support/shared_examples/finders/security/jobs_finder_shared_examples.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples ::Security::JobsFinder do |default_job_types|
+ let(:pipeline) { create(:ci_pipeline) }
+
+ describe '#new' do
+ it "does not get initialized for unsupported job types" do
+ expect { described_class.new(pipeline: pipeline, job_types: [:abcd]) }.to raise_error(
+ ArgumentError,
+ "job_types must be from the following: #{default_job_types}"
+ )
+ end
+ end
+
+ describe '#execute' do
+ let(:finder) { described_class.new(pipeline: pipeline) }
+
+ subject { finder.execute }
+
+ shared_examples 'JobsFinder core functionality' do
+ context 'when the pipeline has no jobs' do
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the pipeline has no Secure jobs' do
+ before do
+ create(:ci_build, pipeline: pipeline)
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the pipeline only has jobs without report artifacts' do
+ before do
+ create(:ci_build, pipeline: pipeline, options: { artifacts: { file: 'test.file' } })
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the pipeline only has jobs with reports unrelated to Secure products' do
+ before do
+ create(:ci_build, pipeline: pipeline, options: { artifacts: { reports: { file: 'test.file' } } })
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when the pipeline only has jobs with reports with paths similar but not identical to Secure reports' do
+ before do
+ create(:ci_build, pipeline: pipeline, options: { artifacts: { reports: { file: 'report:sast:result.file' } } })
+ end
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when there is more than one pipeline' do
+ let(:job_type) { default_job_types.first }
+ let!(:build) { create(:ci_build, job_type, pipeline: pipeline) }
+
+ before do
+ create(:ci_build, job_type, pipeline: create(:ci_pipeline))
+ end
+
+ it 'returns jobs associated with provided pipeline' do
+ is_expected.to eq([build])
+ end
+ end
+ end
+
+ context 'when using legacy CI build metadata config storage' do
+ before do
+ stub_feature_flags(ci_build_metadata_config: false)
+ end
+
+ it_behaves_like 'JobsFinder core functionality'
+ end
+
+ context 'when using the new CI build metadata config storage' do
+ before do
+ stub_feature_flags(ci_build_metadata_config: true)
+ end
+
+ it_behaves_like 'JobsFinder core functionality'
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/label_fields.rb b/spec/support/shared_examples/graphql/label_fields.rb
index b1bfb395bc6..caf5dae409a 100644
--- a/spec/support/shared_examples/graphql/label_fields.rb
+++ b/spec/support/shared_examples/graphql/label_fields.rb
@@ -106,13 +106,11 @@ RSpec.shared_examples 'querying a GraphQL type with labels' do
end
it 'batches queries for labels by title' do
- pending('See: https://gitlab.com/gitlab-org/gitlab/-/issues/217767')
-
multi_selection = query_for(label_b, label_c)
single_selection = query_for(label_d)
expect { run_query(multi_selection) }
- .to issue_same_number_of_queries_as { run_query(single_selection) }
+ .to issue_same_number_of_queries_as { run_query(single_selection) }.ignoring_cached_queries
end
end
diff --git a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
index ec64519cd9c..9c0b398a5c1 100644
--- a/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
+++ b/spec/support/shared_examples/graphql/mutations/boards_create_shared_examples.rb
@@ -65,7 +65,7 @@ RSpec.shared_examples 'boards create mutation' do
let(:params) { { name: name } }
it_behaves_like 'a mutation that returns top-level errors',
- errors: ['group_path or project_path arguments are required']
+ errors: ['Exactly one of group_path or project_path arguments is required']
it 'does not create the board' do
expect { subject }.not_to change { Board.count }
diff --git a/spec/support/shared_examples/graphql/mutations/create_todo_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/create_todo_shared_examples.rb
new file mode 100644
index 00000000000..fbef8be9e88
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/create_todo_shared_examples.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'create todo mutation' do
+ let_it_be(:current_user) { create(:user) }
+
+ let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) }
+
+ context 'when user does not have permission to create todo' do
+ it 'raises error' do
+ expect { mutation.resolve(target_id: global_id_of(target)) }
+ .to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'when user has permission to create todo' do
+ it 'creates a todo' do
+ target.resource_parent.add_reporter(current_user)
+
+ result = mutation.resolve(target_id: global_id_of(target))
+
+ expect(result[:todo]).to be_valid
+ expect(result[:todo].target).to eq(target)
+ expect(result[:todo].state).to eq('pending')
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb
new file mode 100644
index 00000000000..34c58f524cd
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/issues/permission_check_shared_examples.rb
@@ -0,0 +1,52 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'permission level for issue mutation is correctly verified' do |raises_for_all_errors = false|
+ before do
+ issue.assignees = []
+ issue.author = user
+ end
+
+ shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned|
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+
+ context 'even if assigned to the issue' do
+ before do
+ issue.assignees.push(user)
+ end
+
+ it 'does not modify issue' do
+ if raises_for_all_errors || raise_for_assigned
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ else
+ expect(subject[:issue]).to eq issue
+ end
+ end
+ end
+
+ context 'even if author of the issue' do
+ before do
+ issue.author = user
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ context 'when the user is not a project member' do
+ it_behaves_like 'when the user does not have access to the resource', true
+ end
+
+ context 'when the user is a project member' do
+ context 'with guest role' do
+ before do
+ issue.project.add_guest(user)
+ end
+
+ it_behaves_like 'when the user does not have access to the resource', false
+ end
+ end
+end
diff --git a/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb b/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb
new file mode 100644
index 00000000000..1ddbad1cea7
--- /dev/null
+++ b/spec/support/shared_examples/graphql/mutations/merge_requests/permission_check_shared_examples.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'permission level for merge request mutation is correctly verified' do
+ before do
+ merge_request.assignees = []
+ merge_request.reviewers = []
+ merge_request.author = nil
+ end
+
+ shared_examples_for 'when the user does not have access to the resource' do |raise_for_assigned|
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+
+ context 'even if assigned to the merge request' do
+ before do
+ merge_request.assignees.push(user)
+ end
+
+ it 'does not modify merge request' do
+ if raise_for_assigned
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ else
+ # In some cases we simply do nothing instead of raising
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/196241
+ expect(subject[:merge_request]).to eq merge_request
+ end
+ end
+ end
+
+ context 'even if reviewer of the merge request' do
+ before do
+ merge_request.reviewers.push(user)
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+
+ context 'even if author of the merge request' do
+ before do
+ merge_request.author = user
+ end
+
+ it 'raises an error' do
+ expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
+ end
+ end
+ end
+
+ context 'when the user is not a project member' do
+ it_behaves_like 'when the user does not have access to the resource', true
+ end
+
+ context 'when the user is a project member' do
+ context 'with guest role' do
+ before do
+ merge_request.project.add_guest(user)
+ end
+
+ it_behaves_like 'when the user does not have access to the resource', true
+ end
+
+ context 'with reporter role' do
+ before do
+ merge_request.project.add_reporter(user)
+ end
+
+ it_behaves_like 'when the user does not have access to the resource', false
+ 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
index 0a94c6648cc..64f176c5ae9 100644
--- a/spec/support/shared_examples/helm_commands_shared_examples.rb
+++ b/spec/support/shared_examples/helm_commands_shared_examples.rb
@@ -15,6 +15,18 @@ RSpec.shared_examples 'helm command generator' do
end
RSpec.shared_examples 'helm command' do
+ describe 'HELM_VERSION' do
+ subject { command.class::HELM_VERSION }
+
+ it { is_expected.to match(/\d+\.\d+\.\d+/) }
+ end
+
+ describe '#env' do
+ subject { command.env }
+
+ it { is_expected.to be_a Hash }
+ end
+
describe '#rbac?' do
subject { command.rbac? }
diff --git a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
index d0e41605e00..145a7290ac8 100644
--- a/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/cycle_analytics/event_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples_for 'cycle analytics event' do
+RSpec.shared_examples_for 'value stream analytics event' do
let(:params) { {} }
let(:instance) { described_class.new(params) }
diff --git a/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb
new file mode 100644
index 00000000000..20f3270526e
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/background_migration_job_shared_examples.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'marks background migration job records' do
+ it 'marks each job record as succeeded after processing' do
+ create(:background_migration_job, class_name: "::#{described_class.name}",
+ arguments: arguments)
+
+ expect(::Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded).and_call_original
+
+ expect do
+ subject.perform(*arguments)
+ end.to change { ::Gitlab::Database::BackgroundMigrationJob.succeeded.count }.from(0).to(1)
+ end
+
+ it 'returns the number of job records marked as succeeded' do
+ create(:background_migration_job, class_name: "::#{described_class.name}",
+ arguments: arguments)
+
+ jobs_updated = subject.perform(*arguments)
+
+ expect(jobs_updated).to eq(1)
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/database/postgres_model_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/database/postgres_model_shared_examples.rb
new file mode 100644
index 00000000000..ffebbabca58
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/database/postgres_model_shared_examples.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a postgres model' do
+ describe '.by_identifier' do
+ it "finds the #{described_class}" do
+ expect(find(identifier)).to be_a(described_class)
+ end
+
+ it 'raises an error if not found' do
+ expect { find('public.idontexist') }.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it 'raises ArgumentError if given a non-fully qualified identifier' do
+ expect { find('foo') }.to raise_error(ArgumentError, /not fully qualified/)
+ end
+ end
+
+ describe '#to_s' do
+ it 'returns the name' do
+ expect(find(identifier).to_s).to eq(name)
+ end
+ end
+
+ describe '#schema' do
+ it 'returns the schema' do
+ expect(find(identifier).schema).to eq(schema)
+ end
+ end
+
+ describe '#name' do
+ it 'returns the name' do
+ expect(find(identifier).name).to eq(name)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb
new file mode 100644
index 00000000000..e07d3e2dec9
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/middleware/read_only_gitlab_instance_shared_examples.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'write access for a read-only GitLab instance' do
+ include Rack::Test::Methods
+ using RSpec::Parameterized::TableSyntax
+
+ include_context 'with a mocked GitLab instance'
+
+ context 'normal requests to a read-only GitLab instance' do
+ let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['OK']] } }
+
+ it 'expects PATCH requests to be disallowed' do
+ response = request.patch('/test_request')
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+
+ it 'expects PUT requests to be disallowed' do
+ response = request.put('/test_request')
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+
+ it 'expects POST requests to be disallowed' do
+ response = request.post('/test_request')
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+
+ it 'expects a internal POST request to be allowed after a disallowed request' do
+ response = request.post('/test_request')
+
+ expect(response).to be_redirect
+
+ response = request.post("/api/#{API::API.version}/internal")
+
+ expect(response).not_to be_redirect
+ end
+
+ it 'expects DELETE requests to be disallowed' do
+ response = request.delete('/test_request')
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+
+ it 'expects POST of new file that looks like an LFS batch url to be disallowed' do
+ expect(Rails.application.routes).to receive(:recognize_path).and_call_original
+ response = request.post('/root/gitlab-ce/new/master/app/info/lfs/objects/batch')
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+
+ it 'returns last_vistited_url for disallowed request' do
+ response = request.post('/test_request')
+
+ expect(response.location).to eq 'http://localhost/'
+ end
+
+ context 'allowlisted requests' do
+ it 'expects a POST internal request to be allowed' do
+ expect(Rails.application.routes).not_to receive(:recognize_path)
+ response = request.post("/api/#{API::API.version}/internal")
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+ end
+
+ it 'expects a graphql request to be allowed' do
+ response = request.post("/api/graphql")
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+ end
+
+ context 'relative URL is configured' do
+ before do
+ stub_config_setting(relative_url_root: '/gitlab')
+ end
+
+ it 'expects a graphql request to be allowed' do
+ response = request.post("/gitlab/api/graphql")
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+ end
+ end
+
+ context 'sidekiq admin requests' do
+ where(:mounted_at) do
+ [
+ '',
+ '/',
+ '/gitlab',
+ '/gitlab/',
+ '/gitlab/gitlab',
+ '/gitlab/gitlab/'
+ ]
+ end
+
+ with_them do
+ before do
+ stub_config_setting(relative_url_root: mounted_at)
+ end
+
+ it 'allows requests' do
+ path = File.join(mounted_at, 'admin/sidekiq')
+ response = request.post(path)
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+
+ response = request.get(path)
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+ end
+ end
+ end
+
+ where(:description, :path) do
+ 'LFS request to batch' | '/root/rouge.git/info/lfs/objects/batch'
+ 'request to git-upload-pack' | '/root/rouge.git/git-upload-pack'
+ end
+
+ with_them do
+ it "expects a POST #{description} URL to be allowed" do
+ expect(Rails.application.routes).to receive(:recognize_path).and_call_original
+ response = request.post(path)
+
+ expect(response).not_to be_redirect
+ expect(subject).not_to disallow_request
+ end
+ end
+
+ where(:description, :path) do
+ 'LFS request to locks verify' | '/root/rouge.git/info/lfs/locks/verify'
+ 'LFS request to locks create' | '/root/rouge.git/info/lfs/locks'
+ 'LFS request to locks unlock' | '/root/rouge.git/info/lfs/locks/1/unlock'
+ end
+
+ with_them do
+ it "expects a POST #{description} URL not to be allowed" do
+ response = request.post(path)
+
+ expect(response).to be_redirect
+ expect(subject).to disallow_request
+ end
+ end
+ end
+ end
+
+ context 'json requests to a read-only GitLab instance' do
+ let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'application/json' }, ['OK']] } }
+ let(:content_json) { { 'CONTENT_TYPE' => 'application/json' } }
+
+ before do
+ allow(Gitlab::Database).to receive(:read_only?) { true }
+ end
+
+ it 'expects PATCH requests to be disallowed' do
+ response = request.patch('/test_request', content_json)
+
+ expect(response).to disallow_request_in_json
+ end
+
+ it 'expects PUT requests to be disallowed' do
+ response = request.put('/test_request', content_json)
+
+ expect(response).to disallow_request_in_json
+ end
+
+ it 'expects POST requests to be disallowed' do
+ response = request.post('/test_request', content_json)
+
+ expect(response).to disallow_request_in_json
+ end
+
+ it 'expects DELETE requests to be disallowed' do
+ response = request.delete('/test_request', content_json)
+
+ expect(response).to disallow_request_in_json
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/repository_size_checker_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/repository_size_checker_shared_examples.rb
index bb909ffe82a..30413f206f8 100644
--- a/spec/support/shared_examples/lib/gitlab/repository_size_checker_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/repository_size_checker_shared_examples.rb
@@ -17,35 +17,58 @@ RSpec.shared_examples 'checker size not over limit' do
end
RSpec.shared_examples 'checker size exceeded' do
- context 'when current size is below or equal to the limit' do
- let(:current_size) { 50 }
+ context 'when no change size provided' do
+ context 'when current size is below the limit' do
+ let(:current_size) { limit - 1 }
- it 'returns zero' do
- expect(subject.exceeded_size).to eq(0)
+ it 'returns zero' do
+ expect(subject.exceeded_size).to eq(0)
+ end
end
- end
- context 'when current size is over the limit' do
- let(:current_size) { 51 }
+ context 'when current size is equal to the limit' do
+ let(:current_size) { limit }
- it 'returns zero' do
- expect(subject.exceeded_size).to eq(1.megabytes)
+ it 'returns zero' do
+ expect(subject.exceeded_size).to eq(0)
+ end
end
- end
- context 'when change size will be over the limit' do
- let(:current_size) { 50 }
+ context 'when current size is over the limit' do
+ let(:current_size) { limit + 1 }
+ let(:total_repository_size_excess) { 1 }
- it 'returns zero' do
- expect(subject.exceeded_size(1.megabytes)).to eq(1.megabytes)
+ it 'returns a positive number' do
+ expect(subject.exceeded_size).to eq(1.megabyte)
+ end
end
end
- context 'when change size will not be over the limit' do
- let(:current_size) { 49 }
+ context 'when a change size is provided' do
+ let(:change_size) { 1.megabyte }
+
+ context 'when change size will be over the limit' do
+ let(:current_size) { limit }
+
+ it 'returns a positive number' do
+ expect(subject.exceeded_size(change_size)).to eq(1.megabyte)
+ end
+ end
+
+ context 'when change size will be at the limit' do
+ let(:current_size) { limit - 1 }
+
+ it 'returns zero' do
+ expect(subject.exceeded_size(change_size)).to eq(0)
+ end
+ end
+
+ context 'when change size will be under the limit' do
+ let(:current_size) { limit - 2 }
- it 'returns zero' do
- expect(subject.exceeded_size(1.megabytes)).to eq(0)
+ it 'returns zero' do
+ expect(subject.exceeded_size(change_size)).to eq(0)
+ end
end
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb
index d0bef2ad730..e70dfec80b1 100644
--- a/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/search_confidential_filter_shared_examples.rb
@@ -4,66 +4,27 @@ RSpec.shared_examples 'search results filtered by confidential' do
context 'filter not provided (all behavior)' do
let(:filters) { {} }
- context 'when Feature search_filter_by_confidential enabled' do
- it 'returns confidential and not confidential results', :aggregate_failures do
- expect(results.objects('issues')).to include confidential_result
- expect(results.objects('issues')).to include opened_result
- end
- end
-
- context 'when Feature search_filter_by_confidential not enabled' do
- before do
- stub_feature_flags(search_filter_by_confidential: false)
- end
-
- it 'returns confidential and not confidential results', :aggregate_failures do
- expect(results.objects('issues')).to include confidential_result
- expect(results.objects('issues')).to include opened_result
- end
+ it 'returns confidential and not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).to include opened_result
end
end
context 'confidential filter' do
let(:filters) { { confidential: true } }
- context 'when Feature search_filter_by_confidential enabled' do
- it 'returns only confidential results', :aggregate_failures do
- expect(results.objects('issues')).to include confidential_result
- expect(results.objects('issues')).not_to include opened_result
- end
- end
-
- context 'when Feature search_filter_by_confidential not enabled' do
- before do
- stub_feature_flags(search_filter_by_confidential: false)
- end
-
- it 'returns confidential and not confidential results', :aggregate_failures do
- expect(results.objects('issues')).to include confidential_result
- expect(results.objects('issues')).to include opened_result
- end
+ it 'returns only confidential results', :aggregate_failures do
+ expect(results.objects('issues')).to include confidential_result
+ expect(results.objects('issues')).not_to include opened_result
end
end
context 'not confidential filter' do
let(:filters) { { confidential: false } }
- context 'when Feature search_filter_by_confidential enabled' do
- it 'returns not confidential results', :aggregate_failures do
- expect(results.objects('issues')).not_to include confidential_result
- expect(results.objects('issues')).to include opened_result
- end
- end
-
- context 'when Feature search_filter_by_confidential not enabled' do
- before do
- stub_feature_flags(search_filter_by_confidential: false)
- end
-
- it 'returns confidential and not confidential results', :aggregate_failures do
- expect(results.objects('issues')).to include confidential_result
- expect(results.objects('issues')).to include opened_result
- end
+ it 'returns not confidential results', :aggregate_failures do
+ expect(results.objects('issues')).not_to include confidential_result
+ expect(results.objects('issues')).to include opened_result
end
end
end
diff --git a/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb
index 765279a78fe..07d01d5c50e 100644
--- a/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb
+++ b/spec/support/shared_examples/lib/gitlab/search_results_sorted_shared_examples.rb
@@ -2,7 +2,7 @@
RSpec.shared_examples 'search results sorted' do
context 'sort: newest' do
- let(:sort) { 'newest' }
+ let(:sort) { 'created_desc' }
it 'sorts results by created_at' do
expect(results.objects(scope).map(&:id)).to eq([new_result.id, old_result.id, very_old_result.id])
@@ -10,7 +10,7 @@ RSpec.shared_examples 'search results sorted' do
end
context 'sort: oldest' do
- let(:sort) { 'oldest' }
+ let(:sort) { 'created_asc' }
it 'sorts results by created_at' do
expect(results.objects(scope).map(&:id)).to eq([very_old_result.id, old_result.id, new_result.id])
diff --git a/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb
new file mode 100644
index 00000000000..2936bb354cf
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/sidekiq_middleware/strategy_shared_examples.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'deduplicating jobs when scheduling' do |strategy_name|
+ let(:fake_duplicate_job) do
+ instance_double(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob)
+ end
+
+ let(:expected_message) { "dropped #{strategy_name.to_s.humanize.downcase}" }
+
+ subject(:strategy) { Gitlab::SidekiqMiddleware::DuplicateJobs::Strategies.for(strategy_name).new(fake_duplicate_job) }
+
+ describe '#schedule' do
+ before do
+ allow(Gitlab::SidekiqLogging::DeduplicationLogger.instance).to receive(:log)
+ end
+
+ it 'checks for duplicates before yielding' do
+ expect(fake_duplicate_job).to receive(:scheduled?).twice.ordered.and_return(false)
+ expect(fake_duplicate_job).to(
+ receive(:check!)
+ .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL)
+ .ordered
+ .and_return('a jid'))
+ expect(fake_duplicate_job).to receive(:duplicate?).ordered.and_return(false)
+
+ expect { |b| strategy.schedule({}, &b) }.to yield_control
+ end
+
+ it 'checks worker options for scheduled jobs' do
+ expect(fake_duplicate_job).to receive(:scheduled?).ordered.and_return(true)
+ expect(fake_duplicate_job).to receive(:options).ordered.and_return({})
+ expect(fake_duplicate_job).not_to receive(:check!)
+
+ expect { |b| strategy.schedule({}, &b) }.to yield_control
+ end
+
+ context 'job marking' do
+ it 'adds the jid of the existing job to the job hash' do
+ allow(fake_duplicate_job).to receive(:scheduled?).and_return(false)
+ allow(fake_duplicate_job).to receive(:check!).and_return('the jid')
+ allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
+ allow(fake_duplicate_job).to receive(:options).and_return({})
+ job_hash = {}
+
+ expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
+ expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
+
+ strategy.schedule(job_hash) {}
+
+ expect(job_hash).to include('duplicate-of' => 'the jid')
+ end
+
+ context 'scheduled jobs' do
+ let(:time_diff) { 1.minute }
+
+ context 'scheduled in the past' do
+ it 'adds the jid of the existing job to the job hash' do
+ allow(fake_duplicate_job).to receive(:scheduled?).twice.and_return(true)
+ allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now - time_diff)
+ allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true })
+ allow(fake_duplicate_job).to(
+ receive(:check!)
+ .with(Gitlab::SidekiqMiddleware::DuplicateJobs::DuplicateJob::DUPLICATE_KEY_TTL)
+ .and_return('the jid'))
+ allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
+ job_hash = {}
+
+ expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
+ expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
+
+ strategy.schedule(job_hash) {}
+
+ expect(job_hash).to include('duplicate-of' => 'the jid')
+ end
+ end
+
+ context 'scheduled in the future' do
+ it 'adds the jid of the existing job to the job hash' do
+ freeze_time do
+ allow(fake_duplicate_job).to receive(:scheduled?).twice.and_return(true)
+ allow(fake_duplicate_job).to receive(:scheduled_at).and_return(Time.now + time_diff)
+ allow(fake_duplicate_job).to receive(:options).and_return({ including_scheduled: true })
+ allow(fake_duplicate_job).to(
+ receive(:check!).with(time_diff.to_i).and_return('the jid'))
+ allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
+ job_hash = {}
+
+ expect(fake_duplicate_job).to receive(:duplicate?).and_return(true)
+ expect(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
+
+ strategy.schedule(job_hash) {}
+
+ expect(job_hash).to include('duplicate-of' => 'the jid')
+ end
+ end
+ end
+ end
+ end
+
+ context "when the job is droppable" do
+ before do
+ allow(fake_duplicate_job).to receive(:scheduled?).and_return(false)
+ allow(fake_duplicate_job).to receive(:check!).and_return('the jid')
+ allow(fake_duplicate_job).to receive(:duplicate?).and_return(true)
+ allow(fake_duplicate_job).to receive(:options).and_return({})
+ allow(fake_duplicate_job).to receive(:existing_jid).and_return('the jid')
+ allow(fake_duplicate_job).to receive(:droppable?).and_return(true)
+ end
+
+ it 'drops the job' do
+ schedule_result = nil
+
+ expect(fake_duplicate_job).to receive(:droppable?).and_return(true)
+
+ expect { |b| schedule_result = strategy.schedule({}, &b) }.not_to yield_control
+ expect(schedule_result).to be(false)
+ end
+
+ it 'logs that the job was dropped' do
+ fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger)
+
+ expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
+ expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, {})
+
+ strategy.schedule({ 'jid' => 'new jid' }) {}
+ end
+
+ it 'logs the deduplication options of the worker' do
+ fake_logger = instance_double(Gitlab::SidekiqLogging::DeduplicationLogger)
+
+ expect(Gitlab::SidekiqLogging::DeduplicationLogger).to receive(:instance).and_return(fake_logger)
+ allow(fake_duplicate_job).to receive(:options).and_return({ foo: :bar })
+ expect(fake_logger).to receive(:log).with(a_hash_including({ 'jid' => 'new jid' }), expected_message, { foo: :bar })
+
+ strategy.schedule({ 'jid' => 'new jid' }) {}
+ end
+ end
+ end
+end
diff --git a/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb
new file mode 100644
index 00000000000..286305f2506
--- /dev/null
+++ b/spec/support/shared_examples/lib/gitlab/usage_data_counters/issue_activity_shared_examples.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'a tracked issue edit event' do |event|
+ before do
+ stub_application_setting(usage_ping_enabled: true)
+ end
+
+ def count_unique(date_from:, date_to:)
+ Gitlab::UsageDataCounters::HLLRedisCounter.unique_events(event_names: action, start_date: date_from, end_date: date_to)
+ end
+
+ specify do
+ aggregate_failures do
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user1)).to be_truthy
+ expect(track_action(author: user2)).to be_truthy
+ expect(track_action(author: user3, time: time - 3.days)).to be_truthy
+
+ expect(count_unique(date_from: time, date_to: time)).to eq(2)
+ expect(count_unique(date_from: time - 5.days, date_to: 1.day.since(time))).to eq(3)
+ end
+ end
+
+ it 'does not track edit actions if author is not present' do
+ expect(track_action(author: nil)).to be_nil
+ end
+
+ context 'when feature flag track_issue_activity_actions is disabled' do
+ it 'does not track edit actions' do
+ stub_feature_flags(track_issue_activity_actions: false)
+
+ expect(track_action(author: user1)).to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/mailers/notify_shared_examples.rb b/spec/support/shared_examples/mailers/notify_shared_examples.rb
index 7ce7b2161f6..0143bf693c7 100644
--- a/spec/support/shared_examples/mailers/notify_shared_examples.rb
+++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb
@@ -273,3 +273,12 @@ RSpec.shared_examples 'no email is sent' do
expect(subject.message).to be_a_kind_of(ActionMailer::Base::NullMail)
end
end
+
+RSpec.shared_examples 'does not render a manage notifications link' do
+ it do
+ aggregate_failures do
+ expect(subject).not_to have_body_text("Manage all notifications")
+ expect(subject).not_to have_body_text(profile_notifications_url)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/models/application_setting_shared_examples.rb b/spec/support/shared_examples/models/application_setting_shared_examples.rb
index 01513161d24..92fd4363134 100644
--- a/spec/support/shared_examples/models/application_setting_shared_examples.rb
+++ b/spec/support/shared_examples/models/application_setting_shared_examples.rb
@@ -1,84 +1,84 @@
# frozen_string_literal: true
-RSpec.shared_examples 'string of domains' do |attribute|
+RSpec.shared_examples 'string of domains' do |mapped_name, attribute|
it 'sets single domain' do
- setting.method("#{attribute}_raw=").call('example.com')
+ setting.method("#{mapped_name}_raw=").call('example.com')
expect(setting.method(attribute).call).to eq(['example.com'])
end
it 'sets multiple domains with spaces' do
- setting.method("#{attribute}_raw=").call('example.com *.example.com')
+ setting.method("#{mapped_name}_raw=").call('example.com *.example.com')
expect(setting.method(attribute).call).to eq(['example.com', '*.example.com'])
end
it 'sets multiple domains with newlines and a space' do
- setting.method("#{attribute}_raw=").call("example.com\n *.example.com")
+ setting.method("#{mapped_name}_raw=").call("example.com\n *.example.com")
expect(setting.method(attribute).call).to eq(['example.com', '*.example.com'])
end
it 'sets multiple domains with commas' do
- setting.method("#{attribute}_raw=").call("example.com, *.example.com")
+ setting.method("#{mapped_name}_raw=").call("example.com, *.example.com")
expect(setting.method(attribute).call).to eq(['example.com', '*.example.com'])
end
it 'sets multiple domains with semicolon' do
- setting.method("#{attribute}_raw=").call("example.com; *.example.com")
+ setting.method("#{mapped_name}_raw=").call("example.com; *.example.com")
expect(setting.method(attribute).call).to contain_exactly('example.com', '*.example.com')
end
it 'sets multiple domains with mixture of everything' do
- setting.method("#{attribute}_raw=").call("example.com; *.example.com\n test.com\sblock.com yes.com")
+ setting.method("#{mapped_name}_raw=").call("example.com; *.example.com\n test.com\sblock.com yes.com")
expect(setting.method(attribute).call).to contain_exactly('example.com', '*.example.com', 'test.com', 'block.com', 'yes.com')
end
it 'removes duplicates' do
- setting.method("#{attribute}_raw=").call("example.com; example.com; 127.0.0.1; 127.0.0.1")
+ setting.method("#{mapped_name}_raw=").call("example.com; example.com; 127.0.0.1; 127.0.0.1")
expect(setting.method(attribute).call).to contain_exactly('example.com', '127.0.0.1')
end
it 'does not fail with garbage values' do
- setting.method("#{attribute}_raw=").call("example;34543:garbage:fdh5654;")
+ setting.method("#{mapped_name}_raw=").call("example;34543:garbage:fdh5654;")
expect(setting.method(attribute).call).to contain_exactly('example', '34543:garbage:fdh5654')
end
it 'does not raise error with nil' do
- setting.method("#{attribute}_raw=").call(nil)
+ setting.method("#{mapped_name}_raw=").call(nil)
expect(setting.method(attribute).call).to eq([])
end
end
RSpec.shared_examples 'application settings examples' do
context 'restricted signup domains' do
- it_behaves_like 'string of domains', :domain_whitelist
+ it_behaves_like 'string of domains', :domain_allowlist, :domain_allowlist
end
- context 'blacklisted signup domains' do
- it_behaves_like 'string of domains', :domain_blacklist
+ context 'denied signup domains' do
+ it_behaves_like 'string of domains', :domain_denylist, :domain_denylist
it 'sets multiple domain with file' do
- setting.domain_blacklist_file = File.open(Rails.root.join('spec/fixtures/', 'domain_blacklist.txt'))
- expect(setting.domain_blacklist).to contain_exactly('example.com', 'test.com', 'foo.bar')
+ setting.domain_denylist_file = File.open(Rails.root.join('spec/fixtures/', 'domain_denylist.txt'))
+ expect(setting.domain_denylist).to contain_exactly('example.com', 'test.com', 'foo.bar')
end
end
context 'outbound_local_requests_whitelist' do
- it_behaves_like 'string of domains', :outbound_local_requests_whitelist
+ it_behaves_like 'string of domains', :outbound_local_requests_allowlist, :outbound_local_requests_whitelist
- it 'clears outbound_local_requests_whitelist_arrays memoization' do
- setting.outbound_local_requests_whitelist_raw = 'example.com'
+ it 'clears outbound_local_requests_allowlist_arrays memoization' do
+ setting.outbound_local_requests_allowlist_raw = 'example.com'
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
[], [an_object_having_attributes(domain: 'example.com')]
)
- setting.outbound_local_requests_whitelist_raw = 'gitlab.com'
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ setting.outbound_local_requests_allowlist_raw = 'gitlab.com'
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
[], [an_object_having_attributes(domain: 'gitlab.com')]
)
end
end
- context 'outbound_local_requests_whitelist_arrays' do
+ context 'outbound_local_requests_allowlist_arrays' do
it 'separates the IPs and domains' do
setting.outbound_local_requests_whitelist = [
'192.168.1.1',
@@ -118,7 +118,7 @@ RSpec.shared_examples 'application settings examples' do
an_object_having_attributes(domain: 'example.com', port: 8080)
]
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
ip_whitelist, domain_whitelist
)
end
@@ -139,10 +139,10 @@ RSpec.shared_examples 'application settings examples' do
)
end
- it 'clears outbound_local_requests_whitelist_arrays memoization' do
+ it 'clears outbound_local_requests_allowlist_arrays memoization' do
setting.outbound_local_requests_whitelist = ['example.com']
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
[],
[an_object_having_attributes(domain: 'example.com')]
)
@@ -151,7 +151,7 @@ RSpec.shared_examples 'application settings examples' do
['example.com', 'gitlab.com']
)
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
[],
[an_object_having_attributes(domain: 'example.com'), an_object_having_attributes(domain: 'gitlab.com')]
)
@@ -163,7 +163,7 @@ RSpec.shared_examples 'application settings examples' do
setting.add_to_outbound_local_requests_whitelist(['gitlab.com'])
expect(setting.outbound_local_requests_whitelist).to contain_exactly('gitlab.com')
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly(
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly(
[], [an_object_having_attributes(domain: 'gitlab.com')]
)
end
@@ -171,7 +171,7 @@ RSpec.shared_examples 'application settings examples' do
it 'does not raise error with nil' do
setting.outbound_local_requests_whitelist = nil
- expect(setting.outbound_local_requests_whitelist_arrays).to contain_exactly([], [])
+ expect(setting.outbound_local_requests_allowlist_arrays).to contain_exactly([], [])
end
end
diff --git a/spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb b/spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb
index 62d56f2e86e..fe99b1cacd9 100644
--- a/spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb
+++ b/spec/support/shared_examples/models/atomic_internal_id_shared_examples.rb
@@ -76,6 +76,26 @@ RSpec.shared_examples 'AtomicInternalId' do |validate_presence: true|
end
end
+ describe 'supply of internal ids' do
+ let(:scope_value) { scope_attrs.each_value.first }
+ let(:method_name) { :"with_#{scope}_#{internal_id_attribute}_supply" }
+
+ it 'provides a persistent supply of IID values, sensitive to the current state' do
+ iid = rand(1..1000)
+ write_internal_id(iid)
+ instance.public_send(:"track_#{scope}_#{internal_id_attribute}!")
+
+ # Allocate 3 IID values
+ described_class.public_send(method_name, scope_value) do |supply|
+ 3.times { supply.next_value }
+ end
+
+ current_value = described_class.public_send(method_name, scope_value, &:current_value)
+
+ expect(current_value).to eq(iid + 3)
+ end
+ end
+
describe "#reset_scope_internal_id_attribute" do
it 'rewinds the allocated IID' do
expect { ensure_scope_attribute! }.not_to raise_error
diff --git a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb
index 85a7c90ee42..51071ae47c3 100644
--- a/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_core_shared_examples.rb
@@ -25,4 +25,21 @@ RSpec.shared_examples 'cluster application core specs' do |application_name|
describe '.association_name' do
it { expect(described_class.association_name).to eq(:"application_#{subject.name}") }
end
+
+ describe '#helm_command_module' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:helm_major_version, :expected_helm_command_module) do
+ 2 | Gitlab::Kubernetes::Helm::V2
+ 3 | Gitlab::Kubernetes::Helm::V3
+ end
+
+ with_them do
+ subject { described_class.new(cluster: cluster).helm_command_module }
+
+ let(:cluster) { build(:cluster, helm_major_version: helm_major_version)}
+
+ it { is_expected.to eq(expected_helm_command_module) }
+ end
+ end
end
diff --git a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
index ac8022a4726..187a44ec3cd 100644
--- a/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
+++ b/spec/support/shared_examples/models/cluster_application_helm_cert_shared_examples.rb
@@ -6,7 +6,7 @@ RSpec.shared_examples 'cluster application helm specs' do |application_name|
describe '#uninstall_command' do
subject { application.uninstall_command }
- it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::DeleteCommand) }
+ it { is_expected.to be_an_instance_of(Gitlab::Kubernetes::Helm::V3::DeleteCommand) }
it 'has files' do
expect(subject.files).to eq(application.files)
diff --git a/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb b/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
index 8092f87383d..17948d648cb 100644
--- a/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
+++ b/spec/support/shared_examples/models/cycle_analytics_stage_shared_examples.rb
@@ -1,6 +1,6 @@
# frozen_string_literal: true
-RSpec.shared_examples 'cycle analytics stage' do
+RSpec.shared_examples 'value stream analytics stage' do
let(:valid_params) do
{
name: 'My Stage',
@@ -111,7 +111,7 @@ RSpec.shared_examples 'cycle analytics stage' do
end
end
-RSpec.shared_examples 'cycle analytics label based stage' do
+RSpec.shared_examples 'value stream analytics label based stage' do
context 'when creating label based event' do
context 'when the label id is not passed' do
it 'returns validation error when `start_event_label_id` is missing' do
diff --git a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
index 37ee2548dfe..17fd2b836d3 100644
--- a/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
+++ b/spec/support/shared_examples/quick_actions/issuable/time_tracking_quick_action_shared_examples.rb
@@ -13,7 +13,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
end
it 'renders the sidebar component empty state' do
- page.within '.time-tracking-no-tracking-pane' do
+ page.within '[data-testid="noTrackingPane"]' do
expect(page).to have_content 'No estimate or time spent'
end
end
@@ -22,7 +22,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
submit_time('/estimate 3w 1d 1h')
wait_for_requests
- page.within '.time-tracking-estimate-only-pane' do
+ page.within '[data-testid="estimateOnlyPane"]' do
expect(page).to have_content '3w 1d 1h'
end
end
@@ -31,7 +31,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
submit_time('/spend 3w 1d 1h')
wait_for_requests
- page.within '.time-tracking-spend-only-pane' do
+ page.within '[data-testid="spentOnlyPane"]' do
expect(page).to have_content '3w 1d 1h'
end
end
@@ -41,7 +41,7 @@ RSpec.shared_examples 'issuable time tracker' do |issuable_type|
submit_time('/spend 3w 1d 1h')
wait_for_requests
- page.within '.time-tracking-comparison-pane' do
+ page.within '[data-testid="timeTrackingComparisonPane"]' do
expect(page).to have_content '3w 1d 1h'
end
end
diff --git a/spec/support/shared_examples/read_only_message_shared_examples.rb b/spec/support/shared_examples/read_only_message_shared_examples.rb
new file mode 100644
index 00000000000..4ae97ea7748
--- /dev/null
+++ b/spec/support/shared_examples/read_only_message_shared_examples.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'Read-only instance' do |message|
+ it 'shows read-only banner' do
+ visit root_dashboard_path
+
+ expect(page).to have_content(message)
+ end
+end
+
+RSpec.shared_examples 'Read-write instance' do |message|
+ it 'does not show read-only banner' do
+ visit root_dashboard_path
+
+ expect(page).not_to have_content(message)
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
index ec32cb4b2ff..f55043fe64f 100644
--- a/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/debian_packages_shared_examples.rb
@@ -20,7 +20,7 @@ RSpec.shared_context 'Debian repository shared context' do |object_type|
let(:source_package) { 'sample' }
let(:letter) { source_package[0..2] == 'lib' ? source_package[0..3] : source_package[0] }
let(:package_name) { 'libsample0' }
- let(:package_version) { '1.2.3~alpha2-1' }
+ let(:package_version) { '1.2.3~alpha2' }
let(:file_name) { "#{package_name}_#{package_version}_#{architecture}.deb" }
let(:method) { :get }
diff --git a/spec/support/shared_examples/requests/api/discussions_shared_examples.rb b/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
index 6315c10b0c4..a12cb24a513 100644
--- a/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/discussions_shared_examples.rb
@@ -117,15 +117,10 @@ RSpec.shared_examples 'discussions API' do |parent_type, noteable_type, id_name,
expect(response).to have_gitlab_http_status(:unauthorized)
end
- it 'tracks a Notes::CreateService event' do
- expect(Gitlab::Tracking).to receive(:event) do |category, action, data|
- expect(category).to eq('Notes::CreateService')
- expect(action).to eq('execute')
- expect(data[:label]).to eq('note')
- expect(data[:value]).to be_an(Integer)
- end
-
+ it 'tracks a Notes::CreateService event', :snowplow do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions", user), params: { body: 'hi!' }
+
+ expect_snowplow_event(category: 'Notes::CreateService', action: 'execute', label: 'note', value: anything)
end
context 'with notes_create_service_tracking feature flag disabled' do
@@ -133,10 +128,10 @@ RSpec.shared_examples 'discussions API' do |parent_type, noteable_type, id_name,
stub_feature_flags(notes_create_service_tracking: false)
end
- it 'does not track any events' do
- expect(Gitlab::Tracking).not_to receive(:event)
-
+ it 'does not track any events', :snowplow do
post api("/#{parent_type}/#{parent.id}/#{noteable_type}/#{noteable[id_name]}/discussions"), params: { body: 'hi!' }
+
+ expect_no_snowplow_event
end
end
diff --git a/spec/support/shared_examples/requests/api/graphql/read_only_instance_shared_examples.rb b/spec/support/shared_examples/requests/api/graphql/read_only_instance_shared_examples.rb
new file mode 100644
index 00000000000..be163d6aa0e
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/graphql/read_only_instance_shared_examples.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'graphql on a read-only GitLab instance' do
+ include GraphqlHelpers
+
+ context 'mutations' do
+ let(:current_user) { note.author }
+ let!(:note) { create(:note) }
+
+ let(:mutation) do
+ variables = {
+ id: GitlabSchema.id_from_object(note).to_s
+ }
+
+ graphql_mutation(:destroy_note, variables)
+ end
+
+ it 'disallows the query' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(json_response['errors'].first['message']).to eq(Mutations::BaseMutation::ERROR_MESSAGE)
+ end
+
+ it 'does not destroy the Note' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ end.not_to change { Note.count }
+ end
+ end
+
+ context 'read-only queries' do
+ let(:current_user) { create(:user) }
+ let(:project) { create(:project, :repository) }
+
+ before do
+ project.add_developer(current_user)
+ end
+
+ it 'allows the query' do
+ query = graphql_query_for('project', 'fullPath' => project.full_path)
+
+ post_graphql(query, current_user: current_user)
+
+ expect(graphql_data['project']).not_to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb b/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb
new file mode 100644
index 00000000000..02e50b789cc
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/labels_api_shared_examples.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'fetches labels' do
+ it 'returns correct labels' do
+ request
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to include_pagination_headers
+ expect(json_response).to be_an Array
+ expect(json_response).to all(match_schema('public_api/v4/labels/label'))
+ expect(json_response.size).to eq(expected_labels.size)
+ expect(json_response.map {|r| r['name'] }).to match_array(expected_labels)
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb b/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb
new file mode 100644
index 00000000000..54aa9d47dd8
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/multiple_and_scoped_issue_boards_shared_examples.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'multiple and scoped issue boards' do |route_definition|
+ let(:root_url) { route_definition.gsub(":id", board_parent.id.to_s) }
+
+ context 'multiple issue boards' do
+ before do
+ board_parent.add_reporter(user)
+ stub_licensed_features(multiple_group_issue_boards: true)
+ end
+
+ describe "POST #{route_definition}" do
+ it 'creates a board' do
+ post api(root_url, user), params: { name: "new board" }
+
+ expect(response).to have_gitlab_http_status(:created)
+
+ expect(response).to match_response_schema('public_api/v4/board', dir: "ee")
+ end
+ end
+
+ describe "PUT #{route_definition}/:board_id" do
+ let(:url) { "#{root_url}/#{board.id}" }
+
+ it 'updates a board' do
+ put api(url, user), params: { name: 'new name', weight: 4, labels: 'foo, bar' }
+
+ expect(response).to have_gitlab_http_status(:ok)
+
+ expect(response).to match_response_schema('public_api/v4/board', dir: "ee")
+
+ board.reload
+
+ expect(board.name).to eq('new name')
+ expect(board.weight).to eq(4)
+ expect(board.labels.map(&:title)).to contain_exactly('foo', 'bar')
+ end
+
+ it 'does not remove missing attributes from the board' do
+ expect { put api(url, user), params: { name: 'new name' } }
+ .to not_change { board.reload.assignee }
+ .and not_change { board.reload.milestone }
+ .and not_change { board.reload.weight }
+ .and not_change { board.reload.labels.map(&:title).sort }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/board', dir: "ee")
+ end
+
+ it 'allows removing optional attributes' do
+ put api(url, user), params: { name: 'new name', assignee_id: nil, milestone_id: nil, weight: nil, labels: nil }
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response).to match_response_schema('public_api/v4/board', dir: "ee")
+
+ board.reload
+
+ expect(board.name).to eq('new name')
+ expect(board.assignee).to be_nil
+ expect(board.milestone).to be_nil
+ expect(board.weight).to be_nil
+ expect(board.labels).to be_empty
+ end
+ end
+
+ describe "DELETE #{route_definition}/:board_id" do
+ let(:url) { "#{root_url}/#{board.id}" }
+
+ it 'deletes a board' do
+ delete api(url, user)
+
+ expect(response).to have_gitlab_http_status(:no_content)
+ end
+ end
+ end
+
+ context 'with the scoped_issue_board-feature available' do
+ it 'returns the milestone when the `scoped_issue_board` feature is enabled' do
+ stub_licensed_features(scoped_issue_board: true)
+
+ get api(root_url, user)
+
+ expect(json_response.first["milestone"]).not_to be_nil
+ end
+
+ it 'hides the milestone when the `scoped_issue_board` feature is disabled' do
+ stub_licensed_features(scoped_issue_board: false)
+
+ get api(root_url, user)
+
+ expect(json_response.first["milestone"]).to be_nil
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb
new file mode 100644
index 00000000000..d3ad7aa0595
--- /dev/null
+++ b/spec/support/shared_examples/requests/api/npm_packages_shared_examples.rb
@@ -0,0 +1,270 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'handling get metadata requests' do
+ let_it_be(:package_dependency_link1) { create(:packages_dependency_link, package: package, dependency_type: :dependencies) }
+ let_it_be(:package_dependency_link2) { create(:packages_dependency_link, package: package, dependency_type: :devDependencies) }
+ let_it_be(:package_dependency_link3) { create(:packages_dependency_link, package: package, dependency_type: :bundleDependencies) }
+ let_it_be(:package_dependency_link4) { create(:packages_dependency_link, package: package, dependency_type: :peerDependencies) }
+
+ let(:params) { {} }
+ let(:headers) { {} }
+
+ subject { get(url, params: params, headers: headers) }
+
+ shared_examples 'returning the npm package info' do
+ it 'returns the package info' do
+ subject
+
+ expect_a_valid_package_response
+ end
+ end
+
+ shared_examples 'a package that requires auth' do
+ it 'denies request without oauth token' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+
+ context 'with oauth token' do
+ let(:params) { { access_token: token.token } }
+
+ it 'returns the package info with oauth token' do
+ subject
+
+ expect_a_valid_package_response
+ end
+ end
+
+ context 'with job token' do
+ let(:params) { { job_token: job.token } }
+
+ it 'returns the package info with running job token' do
+ subject
+
+ expect_a_valid_package_response
+ end
+
+ it 'denies request without running job token' do
+ job.update!(status: :success)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:unauthorized)
+ end
+ end
+
+ context 'with deploy token' do
+ let(:headers) { build_token_auth_header(deploy_token.token) }
+
+ it 'returns the package info with deploy token' do
+ subject
+
+ expect_a_valid_package_response
+ end
+ end
+ end
+
+ context 'a public project' do
+ it_behaves_like 'returning the npm package info'
+
+ context 'project path with a dot' do
+ before do
+ project.update!(path: 'foo.bar')
+ end
+
+ it_behaves_like 'returning the npm package info'
+ end
+
+ context 'with request forward disabled' do
+ before do
+ stub_application_setting(npm_package_requests_forwarding: false)
+ end
+
+ it_behaves_like 'returning the npm package info'
+
+ context 'with unknown package' do
+ let(:package_name) { 'unknown' }
+
+ it 'returns the proper response' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'with request forward enabled' do
+ before do
+ stub_application_setting(npm_package_requests_forwarding: true)
+ end
+
+ it_behaves_like 'returning the npm package info'
+
+ context 'with unknown package' do
+ let(:package_name) { 'unknown' }
+
+ it 'returns a redirect' do
+ subject
+
+ expect(response).to have_gitlab_http_status(:found)
+ expect(response.headers['Location']).to eq('https://registry.npmjs.org/unknown')
+ end
+
+ it_behaves_like 'a gitlab tracking event', described_class.name, 'npm_request_forward'
+ end
+ end
+ end
+
+ context 'internal project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::INTERNAL)
+ end
+
+ it_behaves_like 'a package that requires auth'
+ end
+
+ context 'private project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ it_behaves_like 'a package that requires auth'
+
+ context 'with guest' do
+ let(:params) { { access_token: token.token } }
+
+ it 'denies request when not enough permissions' do
+ project.add_guest(user)
+
+ subject
+
+ expect(response).to have_gitlab_http_status(:forbidden)
+ end
+ end
+ end
+
+ def expect_a_valid_package_response
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(response.media_type).to eq('application/json')
+ expect(response).to match_response_schema('public_api/v4/packages/npm_package')
+ expect(json_response['name']).to eq(package.name)
+ expect(json_response['versions'][package.version]).to match_schema('public_api/v4/packages/npm_package_version')
+ ::Packages::Npm::PackagePresenter::NPM_VALID_DEPENDENCY_TYPES.each do |dependency_type|
+ expect(json_response.dig('versions', package.version, dependency_type.to_s)).to be_any
+ end
+ expect(json_response['dist-tags']).to match_schema('public_api/v4/packages/npm_package_tags')
+ end
+end
+
+RSpec.shared_examples 'handling get dist tags requests' do
+ let_it_be(:package_tag1) { create(:packages_tag, package: package) }
+ let_it_be(:package_tag2) { create(:packages_tag, package: package) }
+
+ let(:params) { {} }
+
+ subject { get(url, params: params) }
+
+ context 'with public project' do
+ context 'with authenticated user' do
+ let(:params) { { private_token: personal_access_token.token } }
+
+ it_behaves_like 'returns package tags', :maintainer
+ it_behaves_like 'returns package tags', :developer
+ it_behaves_like 'returns package tags', :reporter
+ it_behaves_like 'returns package tags', :guest
+ end
+
+ context 'with unauthenticated user' do
+ it_behaves_like 'returns package tags', :no_type
+ end
+ end
+
+ context 'with private project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context 'with authenticated user' do
+ let(:params) { { private_token: personal_access_token.token } }
+
+ it_behaves_like 'returns package tags', :maintainer
+ it_behaves_like 'returns package tags', :developer
+ it_behaves_like 'returns package tags', :reporter
+ it_behaves_like 'rejects package tags access', :guest, :forbidden
+ end
+
+ context 'with unauthenticated user' do
+ it_behaves_like 'rejects package tags access', :no_type, :not_found
+ end
+ end
+end
+
+RSpec.shared_examples 'handling create dist tag requests' do
+ let_it_be(:tag_name) { 'test' }
+
+ let(:params) { {} }
+ let(:env) { {} }
+ let(:version) { package.version }
+
+ subject { put(url, env: env, params: params) }
+
+ context 'with public project' do
+ context 'with authenticated user' do
+ let(:params) { { private_token: personal_access_token.token } }
+ let(:env) { { 'api.request.body': version } }
+
+ it_behaves_like 'create package tag', :maintainer
+ it_behaves_like 'create package tag', :developer
+ it_behaves_like 'rejects package tags access', :reporter, :forbidden
+ it_behaves_like 'rejects package tags access', :guest, :forbidden
+ end
+
+ context 'with unauthenticated user' do
+ it_behaves_like 'rejects package tags access', :no_type, :unauthorized
+ end
+ end
+end
+
+RSpec.shared_examples 'handling delete dist tag requests' do
+ let_it_be(:package_tag) { create(:packages_tag, package: package) }
+
+ let(:params) { {} }
+ let(:tag_name) { package_tag.name }
+
+ subject { delete(url, params: params) }
+
+ context 'with public project' do
+ context 'with authenticated user' do
+ let(:params) { { private_token: personal_access_token.token } }
+
+ it_behaves_like 'delete package tag', :maintainer
+ it_behaves_like 'rejects package tags access', :developer, :forbidden
+ it_behaves_like 'rejects package tags access', :reporter, :forbidden
+ it_behaves_like 'rejects package tags access', :guest, :forbidden
+ end
+
+ context 'with unauthenticated user' do
+ it_behaves_like 'rejects package tags access', :no_type, :unauthorized
+ end
+ end
+
+ context 'with private project' do
+ before do
+ project.update!(visibility_level: Gitlab::VisibilityLevel::PRIVATE)
+ end
+
+ context 'with authenticated user' do
+ let(:params) { { private_token: personal_access_token.token } }
+
+ it_behaves_like 'delete package tag', :maintainer
+ it_behaves_like 'rejects package tags access', :developer, :forbidden
+ it_behaves_like 'rejects package tags access', :reporter, :forbidden
+ it_behaves_like 'rejects package tags access', :guest, :forbidden
+ end
+
+ context 'with unauthenticated user' do
+ it_behaves_like 'rejects package tags access', :no_type, :unauthorized
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/api/packages_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
index d730ed53109..3833604e304 100644
--- a/spec/support/shared_examples/requests/api/packages_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_shared_examples.rb
@@ -128,9 +128,13 @@ RSpec.shared_examples 'job token for package uploads' do
end
RSpec.shared_examples 'a package tracking event' do |category, action|
- it "creates a gitlab tracking event #{action}" do
- expect(Gitlab::Tracking).to receive(:event).with(category, action, {})
+ before do
+ stub_feature_flags(collect_package_events: true)
+ end
+ it "creates a gitlab tracking event #{action}", :snowplow do
expect { subject }.to change { Packages::Event.count }.by(1)
+
+ expect_snowplow_event(category: category, action: action)
end
end
diff --git a/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb b/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb
index a371d380f47..2c203dc096e 100644
--- a/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/packages_tags_shared_examples.rb
@@ -40,7 +40,7 @@ RSpec.shared_examples 'returns package tags' do |user_type|
context 'with invalid package name' do
where(:package_name, :status) do
'%20' | :bad_request
- nil | :forbidden
+ nil | :not_found
end
with_them do
@@ -95,7 +95,7 @@ RSpec.shared_examples 'create package tag' do |user_type|
context 'with invalid package name' do
where(:package_name, :status) do
- 'unknown' | :forbidden
+ 'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
@@ -160,7 +160,7 @@ RSpec.shared_examples 'delete package tag' do |user_type|
context 'with invalid package name' do
where(:package_name, :status) do
- 'unknown' | :forbidden
+ 'unknown' | :not_found
'' | :not_found
'%20' | :bad_request
end
diff --git a/spec/support/shared_examples/requests/api/tracking_shared_examples.rb b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb
index 2e6feae3f98..826139635ed 100644
--- a/spec/support/shared_examples/requests/api/tracking_shared_examples.rb
+++ b/spec/support/shared_examples/requests/api/tracking_shared_examples.rb
@@ -1,9 +1,9 @@
# frozen_string_literal: true
RSpec.shared_examples 'a gitlab tracking event' do |category, action|
- it "creates a gitlab tracking event #{action}" do
- expect(Gitlab::Tracking).to receive(:event).with(category, action, {})
-
+ it "creates a gitlab tracking event #{action}", :snowplow do
subject
+
+ expect_snowplow_event(category: category, action: action)
end
end
diff --git a/spec/support/shared_examples/requests/lfs_http_shared_examples.rb b/spec/support/shared_examples/requests/lfs_http_shared_examples.rb
index 48c5a5933e6..4ae77179527 100644
--- a/spec/support/shared_examples/requests/lfs_http_shared_examples.rb
+++ b/spec/support/shared_examples/requests/lfs_http_shared_examples.rb
@@ -2,42 +2,252 @@
RSpec.shared_examples 'LFS http 200 response' do
it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 200 }
+ let(:response_code) { :ok }
+ end
+end
+
+RSpec.shared_examples 'LFS http 200 blob response' do
+ it_behaves_like 'LFS http expected response code and message' do
+ let(:response_code) { :ok }
+ let(:content_type) { Repositories::LfsApiController::LFS_TRANSFER_CONTENT_TYPE }
+ let(:response_headers) { { 'X-Sendfile' => lfs_object.file.path } }
+ end
+end
+
+RSpec.shared_examples 'LFS http 200 workhorse response' do
+ it_behaves_like 'LFS http expected response code and message' do
+ let(:response_code) { :ok }
+ let(:content_type) { Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE }
end
end
RSpec.shared_examples 'LFS http 401 response' do
it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 401 }
+ let(:response_code) { :unauthorized }
+ let(:content_type) { 'text/plain' }
end
end
RSpec.shared_examples 'LFS http 403 response' do
it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 403 }
+ let(:response_code) { :forbidden }
let(:message) { 'Access forbidden. Check your access level.' }
end
end
RSpec.shared_examples 'LFS http 501 response' do
it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 501 }
+ let(:response_code) { :not_implemented }
let(:message) { 'Git LFS is not enabled on this GitLab server, contact your admin.' }
end
end
RSpec.shared_examples 'LFS http 404 response' do
it_behaves_like 'LFS http expected response code and message' do
- let(:response_code) { 404 }
+ let(:response_code) { :not_found }
end
end
RSpec.shared_examples 'LFS http expected response code and message' do
let(:response_code) { }
- let(:message) { }
+ let(:response_headers) { {} }
+ let(:content_type) { LfsRequest::CONTENT_TYPE }
+ let(:message) {}
- it 'responds with the expected response code and message' do
+ specify do
expect(response).to have_gitlab_http_status(response_code)
+ expect(response.headers.to_hash).to include(response_headers)
+ expect(response.media_type).to match(content_type)
expect(json_response['message']).to eq(message) if message
end
end
+
+RSpec.shared_examples 'LFS http requests' do
+ include LfsHttpHelpers
+
+ let(:authorize_guest) {}
+ let(:authorize_download) {}
+ let(:authorize_upload) {}
+
+ let(:lfs_object) { create(:lfs_object, :with_file) }
+ let(:sample_oid) { lfs_object.oid }
+
+ let(:authorization) { authorize_user }
+ let(:headers) do
+ {
+ 'Authorization' => authorization,
+ 'X-Sendfile-Type' => 'X-Sendfile'
+ }
+ end
+
+ let(:request_download) do
+ get objects_url(container, sample_oid), params: {}, headers: headers
+ end
+
+ let(:request_upload) do
+ post_lfs_json batch_url(container), upload_body(multiple_objects), headers
+ end
+
+ before do
+ stub_lfs_setting(enabled: true)
+ end
+
+ context 'when LFS is disabled globally' do
+ before do
+ stub_lfs_setting(enabled: false)
+ end
+
+ describe 'download request' do
+ before do
+ request_download
+ end
+
+ it_behaves_like 'LFS http 501 response'
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 501 response'
+ end
+ end
+
+ context 'unauthenticated' do
+ let(:headers) { {} }
+
+ describe 'download request' do
+ before do
+ request_download
+ end
+
+ it_behaves_like 'LFS http 401 response'
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 401 response'
+ end
+ end
+
+ context 'without access' do
+ describe 'download request' do
+ before do
+ request_download
+ end
+
+ it_behaves_like 'LFS http 404 response'
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 404 response'
+ end
+ end
+
+ context 'with guest access' do
+ before do
+ authorize_guest
+ end
+
+ describe 'download request' do
+ before do
+ request_download
+ end
+
+ it_behaves_like 'LFS http 404 response'
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 404 response'
+ end
+ end
+
+ context 'with download permission' do
+ before do
+ authorize_download
+ end
+
+ describe 'download request' do
+ before do
+ request_download
+ end
+
+ it_behaves_like 'LFS http 200 blob response'
+
+ context 'when container does not exist' do
+ def objects_url(*args)
+ super.sub(container.full_path, 'missing/path')
+ end
+
+ it_behaves_like 'LFS http 404 response'
+ end
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 403 response'
+ end
+ end
+
+ context 'with upload permission' do
+ before do
+ authorize_upload
+ end
+
+ describe 'upload request' do
+ before do
+ request_upload
+ end
+
+ it_behaves_like 'LFS http 200 response'
+ end
+ end
+
+ describe 'deprecated API' do
+ shared_examples 'deprecated request' do
+ before do
+ request
+ end
+
+ it_behaves_like 'LFS http expected response code and message' do
+ let(:response_code) { 501 }
+ let(:message) { 'Server supports batch API only, please update your Git LFS client to version 1.0.1 and up.' }
+ end
+ end
+
+ context 'when fetching LFS object using deprecated API' do
+ subject(:request) do
+ get deprecated_objects_url(container, sample_oid), params: {}, headers: headers
+ end
+
+ it_behaves_like 'deprecated request'
+ end
+
+ context 'when handling LFS request using deprecated API' do
+ subject(:request) do
+ post_lfs_json deprecated_objects_url(container), nil, headers
+ end
+
+ it_behaves_like 'deprecated request'
+ end
+
+ def deprecated_objects_url(container, oid = nil)
+ File.join(["#{container.http_url_to_repo}/info/lfs/objects/", oid].compact)
+ end
+ end
+end
diff --git a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
index 730df4dc5ab..d4ee68309ff 100644
--- a/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
+++ b/spec/support/shared_examples/requests/rack_attack_shared_examples.rb
@@ -81,8 +81,15 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do
end
it 'logs RackAttack info into structured logs' do
- requests_per_period.times do
- make_request(request_args)
+ control_count = 0
+
+ requests_per_period.times do |i|
+ if i == 0
+ control_count = ActiveRecord::QueryRecorder.new { make_request(request_args) }.count
+ else
+ make_request(request_args)
+ end
+
expect(response).not_to have_gitlab_http_status(:too_many_requests)
end
@@ -93,13 +100,15 @@ RSpec.shared_examples 'rate-limited token-authenticated requests' do
request_method: request_method,
path: request_args.first,
user_id: user.id,
- username: user.username,
- throttle_type: throttle_types[throttle_setting_prefix]
+ 'meta.user' => user.username,
+ matched: throttle_types[throttle_setting_prefix]
}
expect(Gitlab::AuthLogger).to receive(:error).with(arguments).once
- expect_rejection { make_request(request_args) }
+ expect_rejection do
+ expect { make_request(request_args) }.not_to exceed_query_limit(control_count)
+ end
end
end
@@ -210,8 +219,15 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do
end
it 'logs RackAttack info into structured logs' do
- requests_per_period.times do
- request_authenticated_web_url
+ control_count = 0
+
+ requests_per_period.times do |i|
+ if i == 0
+ control_count = ActiveRecord::QueryRecorder.new { request_authenticated_web_url }.count
+ else
+ request_authenticated_web_url
+ end
+
expect(response).not_to have_gitlab_http_status(:too_many_requests)
end
@@ -222,13 +238,12 @@ RSpec.shared_examples 'rate-limited web authenticated requests' do
request_method: request_method,
path: url_that_requires_authentication,
user_id: user.id,
- username: user.username,
- throttle_type: throttle_types[throttle_setting_prefix]
+ 'meta.user' => user.username,
+ matched: throttle_types[throttle_setting_prefix]
}
expect(Gitlab::AuthLogger).to receive(:error).with(arguments).once
-
- request_authenticated_web_url
+ expect { request_authenticated_web_url }.not_to exceed_query_limit(control_count)
end
end
diff --git a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
index a90a2dc3667..9af6ec45e49 100644
--- a/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
+++ b/spec/support/shared_examples/serializers/note_entity_shared_examples.rb
@@ -5,8 +5,21 @@ RSpec.shared_examples 'note entity' do
context 'basic note' do
it 'exposes correct elements' do
- expect(subject).to include(:type, :author, :note, :note_html, :current_user, :discussion_id,
- :emoji_awardable, :award_emoji, :report_abuse_path, :attachment, :noteable_note_url, :resolvable)
+ expect(subject).to include(
+ :attachment,
+ :author,
+ :award_emoji,
+ :base_discussion,
+ :current_user,
+ :discussion_id,
+ :emoji_awardable,
+ :note,
+ :note_html,
+ :noteable_note_url,
+ :report_abuse_path,
+ :resolvable,
+ :type
+ )
end
it 'does not expose elements for specific notes cases' do
@@ -20,6 +33,39 @@ RSpec.shared_examples 'note entity' do
it 'does not expose web_url for author' do
expect(subject[:author]).not_to include(:web_url)
end
+
+ it 'exposes permission fields on current_user' do
+ expect(subject[:current_user]).to include(:can_edit, :can_award_emoji, :can_resolve, :can_resolve_discussion)
+ end
+
+ describe ':can_resolve_discussion' do
+ context 'discussion is resolvable' do
+ before do
+ expect(note.discussion).to receive(:resolvable?).and_return(true)
+ end
+
+ context 'user can resolve' do
+ it 'is true' do
+ expect(note.discussion).to receive(:can_resolve?).with(user).and_return(true)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_truthy
+ end
+ end
+
+ context 'user cannot resolve' do
+ it 'is false' do
+ expect(note.discussion).to receive(:can_resolve?).with(user).and_return(false)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_falsey
+ end
+ end
+ end
+
+ context 'discussion is not resolvable' do
+ it 'is false' do
+ expect(note.discussion).to receive(:resolvable?).and_return(false)
+ expect(subject[:current_user][:can_resolve_discussion]).to be_falsey
+ end
+ end
+ end
end
context 'when note was edited' do
diff --git a/spec/support/shared_examples/services/alert_management_shared_examples.rb b/spec/support/shared_examples/services/alert_management_shared_examples.rb
index 1ae74979b7a..003705ca21c 100644
--- a/spec/support/shared_examples/services/alert_management_shared_examples.rb
+++ b/spec/support/shared_examples/services/alert_management_shared_examples.rb
@@ -8,11 +8,11 @@ RSpec.shared_examples 'creates an alert management alert' do
end
it 'executes the alert service hooks' do
- slack_service = create(:service, type: 'SlackService', project: project, alert_events: true, active: true)
+ expect_next_instance_of(AlertManagement::Alert) do |alert|
+ expect(alert).to receive(:execute_services)
+ end
subject
-
- expect(ProjectServiceWorker).to have_received(:perform_async).with(slack_service.id, an_instance_of(Hash))
end
end
diff --git a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
index 5b95a5753a1..7b277d4bede 100644
--- a/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
+++ b/spec/support/shared_examples/services/common_system_notes_shared_examples.rb
@@ -17,13 +17,13 @@ RSpec.shared_examples 'system note creation' do |update_params, note_text|
end
end
-RSpec.shared_examples 'draft notes creation' do |wip_action|
+RSpec.shared_examples 'draft notes creation' do |action|
subject { described_class.new(project, user).execute(issuable, old_labels: []) }
it 'creates Draft toggle and title change notes' do
expect { subject }.to change { Note.count }.from(0).to(2)
- expect(Note.first.note).to match("#{wip_action} as a **Work In Progress**")
+ expect(Note.first.note).to match("marked this merge request as **#{action}**")
expect(Note.second.note).to match('changed title')
end
end
diff --git a/spec/support/shared_examples/services/jira_import/user_mapper_services_shared_examples.rb b/spec/support/shared_examples/services/jira_import/user_mapper_services_shared_examples.rb
index 7fc7ff8a8de..cbe5c7d89db 100644
--- a/spec/support/shared_examples/services/jira_import/user_mapper_services_shared_examples.rb
+++ b/spec/support/shared_examples/services/jira_import/user_mapper_services_shared_examples.rb
@@ -3,7 +3,6 @@
RSpec.shared_examples 'mapping jira users' do
let(:client) { double }
- let_it_be(:project) { create(:project) }
let_it_be(:jira_service) { create(:jira_service, project: project, active: true) }
before do
@@ -11,7 +10,7 @@ RSpec.shared_examples 'mapping jira users' do
allow(client).to receive(:get).with(url).and_return(jira_users)
end
- subject { described_class.new(jira_service, start_at) }
+ subject { described_class.new(current_user, project, start_at) }
context 'jira_users is nil' do
let(:jira_users) { nil }
@@ -22,18 +21,27 @@ RSpec.shared_examples 'mapping jira users' do
end
context 'when jira_users is present' do
- # TODO: now we only create an array in a proper format
- # mapping is tracked in https://gitlab.com/gitlab-org/gitlab/-/issues/219023
let(:mapped_users) do
[
- { jira_account_id: 'abcd', jira_display_name: 'user1', jira_email: nil, gitlab_id: nil, gitlab_username: nil, gitlab_name: nil },
- { jira_account_id: 'efg', jira_display_name: nil, jira_email: nil, gitlab_id: nil, gitlab_username: nil, gitlab_name: nil },
- { jira_account_id: 'hij', jira_display_name: 'user3', jira_email: 'user3@example.com', gitlab_id: nil, gitlab_username: nil, gitlab_name: nil }
+ { jira_account_id: 'abcd', jira_display_name: 'User-Name1', jira_email: nil, gitlab_id: user_1.id },
+ { jira_account_id: 'efg', jira_display_name: 'username-2', jira_email: nil, gitlab_id: user_2.id },
+ { jira_account_id: 'hij', jira_display_name: nil, jira_email: nil, gitlab_id: nil },
+ { jira_account_id: '123', jira_display_name: 'user-4', jira_email: 'user-4@example.com', gitlab_id: user_4.id },
+ { jira_account_id: '456', jira_display_name: 'username5foo', jira_email: 'user-5@example.com', gitlab_id: nil },
+ { jira_account_id: '789', jira_display_name: 'user-6', jira_email: 'user-6@example.com', gitlab_id: nil },
+ { jira_account_id: 'xyz', jira_display_name: 'username-7', jira_email: 'user-7@example.com', gitlab_id: nil },
+ { jira_account_id: 'vhk', jira_display_name: 'user-8', jira_email: 'user8_email@example.com', gitlab_id: user_8.id },
+ { jira_account_id: 'uji', jira_display_name: 'user-9', jira_email: 'uji@example.com', gitlab_id: user_1.id }
]
end
it 'returns users mapped to Gitlab' do
expect(subject.execute).to eq(mapped_users)
end
+
+ # 1 query for getting matched users, 3 queries for MembersFinder
+ it 'runs only 4 queries' do
+ expect { subject }.not_to exceed_query_limit(4)
+ end
end
end
diff --git a/spec/support/shared_examples/services/packages_shared_examples.rb b/spec/support/shared_examples/services/packages_shared_examples.rb
index 65f4b3b5513..7987f2c296b 100644
--- a/spec/support/shared_examples/services/packages_shared_examples.rb
+++ b/spec/support/shared_examples/services/packages_shared_examples.rb
@@ -8,8 +8,8 @@ RSpec.shared_examples 'assigns build to package' do
it 'assigns the pipeline to the package' do
package = subject
- expect(package.build_info).to be_present
- expect(package.build_info.pipeline).to eq job.pipeline
+ expect(package.original_build_info).to be_present
+ expect(package.original_build_info.pipeline).to eq job.pipeline
end
end
end
diff --git a/spec/support/shared_examples/services/pages_size_limit_shared_examples.rb b/spec/support/shared_examples/services/pages_size_limit_shared_examples.rb
index 15bf0d3698a..d9e906ebb75 100644
--- a/spec/support/shared_examples/services/pages_size_limit_shared_examples.rb
+++ b/spec/support/shared_examples/services/pages_size_limit_shared_examples.rb
@@ -4,6 +4,7 @@ RSpec.shared_examples 'pages size limit is' do |size_limit|
context "when size is below the limit" do
before do
allow(metadata).to receive(:total_size).and_return(size_limit - 1.megabyte)
+ allow(metadata).to receive(:entries).and_return([])
end
it 'updates pages correctly' do
@@ -17,6 +18,7 @@ RSpec.shared_examples 'pages size limit is' do |size_limit|
context "when size is above the limit" do
before do
allow(metadata).to receive(:total_size).and_return(size_limit + 1.megabyte)
+ allow(metadata).to receive(:entries).and_return([])
end
it 'limits the maximum size of gitlab pages' do
diff --git a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb b/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb
deleted file mode 100644
index 5a9a3dfc2d2..00000000000
--- a/spec/support/shared_examples/uploaders/workers/object_storage/migrate_uploads_shared_examples.rb
+++ /dev/null
@@ -1,120 +0,0 @@
-# frozen_string_literal: true
-
-# Expects the calling spec to define:
-# - model_class
-# - mounted_as
-# - to_store
-RSpec.shared_examples 'uploads migration worker' do
- def perform(uploads, store = nil)
- described_class.new.perform(uploads.ids, model_class.to_s, mounted_as, store || to_store)
- rescue ObjectStorage::MigrateUploadsWorker::Report::MigrationFailures
- # swallow
- end
-
- describe '.enqueue!' do
- def enqueue!
- described_class.enqueue!(uploads, model_class, mounted_as, to_store)
- end
-
- it 'is guarded by .sanity_check!' do
- expect(described_class).to receive(:perform_async)
- expect(described_class).to receive(:sanity_check!)
-
- enqueue!
- end
-
- context 'sanity_check! fails' do
- include_context 'sanity_check! fails'
-
- it 'does not enqueue a job' do
- expect(described_class).not_to receive(:perform_async)
-
- expect { enqueue! }.to raise_error(described_class::SanityCheckError)
- end
- end
- end
-
- describe '.sanity_check!' do
- shared_examples 'raises a SanityCheckError' do |expected_message|
- let(:mount_point) { nil }
-
- it do
- expect { described_class.sanity_check!(uploads, model_class, mount_point) }
- .to raise_error(described_class::SanityCheckError).with_message(expected_message)
- end
- end
-
- context 'uploader types mismatch' do
- let!(:outlier) { create(:upload, uploader: 'GitlabUploader') }
-
- include_examples 'raises a SanityCheckError', /Multiple uploaders found/
- end
-
- context 'mount point not found' do
- include_examples 'raises a SanityCheckError', /Mount point [a-z:]+ not found in/ do
- let(:mount_point) { :potato }
- end
- end
- end
-
- describe '#perform' do
- shared_examples 'outputs correctly' do |success: 0, failures: 0|
- total = success + failures
-
- if success > 0
- it 'outputs the reports' do
- expect(Gitlab::AppLogger).to receive(:info).with(%r{Migrated #{success}/#{total} files})
-
- perform(uploads)
- end
- end
-
- if failures > 0
- it 'outputs upload failures' do
- expect(Gitlab::AppLogger).to receive(:warn).with(/Error .* I am a teapot/)
-
- perform(uploads)
- end
- end
- end
-
- it_behaves_like 'outputs correctly', success: 10
-
- it 'migrates files to remote storage' do
- perform(uploads)
-
- expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
- end
-
- context 'reversed' do
- let(:to_store) { ObjectStorage::Store::LOCAL }
-
- before do
- perform(uploads, ObjectStorage::Store::REMOTE)
- end
-
- it 'migrates files to local storage' do
- expect(Upload.where(store: ObjectStorage::Store::REMOTE).count).to eq(10)
-
- perform(uploads)
-
- expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(10)
- end
- end
-
- context 'migration is unsuccessful' do
- before do
- allow_any_instance_of(ObjectStorage::Concern)
- .to receive(:migrate!).and_raise(CarrierWave::UploadError, 'I am a teapot.')
- end
-
- it_behaves_like 'outputs correctly', failures: 10
- end
- end
-end
-
-RSpec.shared_context 'sanity_check! fails' do
- before do
- expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError)
- end
-end
diff --git a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
index 50879969e90..37f44f98cda 100644
--- a/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
+++ b/spec/support/shared_examples/workers/concerns/reenqueuer_shared_examples.rb
@@ -1,40 +1,32 @@
# frozen_string_literal: true
-# Expects `worker_class` to be defined
+# Expects `subject` to be a job/worker instance
RSpec.shared_examples 'reenqueuer' do
- subject(:job) { worker_class.new }
-
before do
- allow(job).to receive(:sleep) # faster tests
+ allow(subject).to receive(:sleep) # faster tests
end
it 'implements lease_timeout' do
- expect(job.lease_timeout).to be_a(ActiveSupport::Duration)
+ expect(subject.lease_timeout).to be_a(ActiveSupport::Duration)
end
describe '#perform' do
it 'tries to obtain a lease' do
- expect_to_obtain_exclusive_lease(job.lease_key)
+ expect_to_obtain_exclusive_lease(subject.lease_key)
- job.perform
+ subject.perform
end
end
end
-# Example usage:
-#
-# it_behaves_like 'it is rate limited to 1 call per', 5.seconds do
-# subject { described_class.new }
-# let(:rate_limited_method) { subject.perform }
-# end
-#
-RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
+# Expects `subject` to be a job/worker instance
+RSpec.shared_examples '#perform is rate limited to 1 call per' do |minimum_duration|
before do
# Allow Timecop freeze and travel without the block form
Timecop.safe_mode = false
Timecop.freeze
- time_travel_during_rate_limited_method(actual_duration)
+ time_travel_during_perform(actual_duration)
end
after do
@@ -48,7 +40,7 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'sleeps exactly the minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(minimum_duration))
- rate_limited_method
+ subject.perform
end
end
@@ -58,7 +50,7 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'sleeps 90% of minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.9 * minimum_duration))
- rate_limited_method
+ subject.perform
end
end
@@ -68,7 +60,7 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'sleeps 10% of minimum duration' do
expect(subject).to receive(:sleep).with(a_value_within(0.01).of(0.1 * minimum_duration))
- rate_limited_method
+ subject.perform
end
end
@@ -78,7 +70,7 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
- rate_limited_method
+ subject.perform
end
end
@@ -88,7 +80,7 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
- rate_limited_method
+ subject.perform
end
end
@@ -98,11 +90,11 @@ RSpec.shared_examples 'it is rate limited to 1 call per' do |minimum_duration|
it 'does not sleep' do
expect(subject).not_to receive(:sleep)
- rate_limited_method
+ subject.perform
end
end
- def time_travel_during_rate_limited_method(actual_duration)
+ def time_travel_during_perform(actual_duration)
# Save the original implementation of ensure_minimum_duration
original_ensure_minimum_duration = subject.method(:ensure_minimum_duration)
diff --git a/spec/support/snowplow.rb b/spec/support/snowplow.rb
index 58812b8f4e6..b67fa96fab8 100644
--- a/spec/support/snowplow.rb
+++ b/spec/support/snowplow.rb
@@ -1,22 +1,24 @@
# frozen_string_literal: true
RSpec.configure do |config|
+ config.include SnowplowHelpers, :snowplow
+
config.before(:each, :snowplow) do
# Using a high buffer size to not cause early flushes
buffer_size = 100
# WebMock is set up to allow requests to `localhost`
host = 'localhost'
- allow(Gitlab::Tracking)
+ allow_any_instance_of(Gitlab::Tracking::Destinations::Snowplow)
.to receive(:emitter)
.and_return(SnowplowTracker::Emitter.new(host, buffer_size: buffer_size))
stub_application_setting(snowplow_enabled: true)
- allow(Gitlab::Tracking).to receive(:event).and_call_original
+ allow(Gitlab::Tracking).to receive(:event).and_call_original # rubocop:disable RSpec/ExpectGitlabTracking
end
config.after(:each, :snowplow) do
- Gitlab::Tracking.send(:snowplow).flush
+ Gitlab::Tracking.send(:snowplow).send(:tracker).flush
end
end
diff --git a/spec/tasks/gitlab/gitaly_rake_spec.rb b/spec/tasks/gitlab/gitaly_rake_spec.rb
index c59370a7a32..efc983d526f 100644
--- a/spec/tasks/gitlab/gitaly_rake_spec.rb
+++ b/spec/tasks/gitlab/gitaly_rake_spec.rb
@@ -89,7 +89,7 @@ RSpec.describe 'gitlab:gitaly namespace rake task' do
let(:command) do
%W[make
BUNDLE_FLAGS=--no-deployment
- BUNDLE_PATH=#{Bundler.bundle_path}]
+ GEM_HOME=#{Bundler.bundle_path}]
end
before do
diff --git a/spec/tasks/gitlab/packages/events_rake_spec.rb b/spec/tasks/gitlab/packages/events_rake_spec.rb
new file mode 100644
index 00000000000..ac28f9b5fc2
--- /dev/null
+++ b/spec/tasks/gitlab/packages/events_rake_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'rake_helper'
+
+RSpec.describe 'gitlab:packages:events namespace rake task' do
+ before :all do
+ Rake.application.rake_require 'tasks/gitlab/packages/events'
+ end
+
+ describe 'generate' do
+ subject do
+ file = double('file')
+ yml_file = nil
+
+ allow(file).to receive(:<<) { |contents| yml_file = contents }
+ allow(File).to receive(:open).and_yield(file)
+
+ run_rake_task('gitlab:packages:events:generate')
+
+ YAML.safe_load(yml_file)
+ end
+
+ it 'excludes guest events' do
+ expect(subject.find { |event| event['name'].include?("guest") }).to be_nil
+ end
+
+ ::Packages::Event::EVENT_SCOPES.keys.each do |event_scope|
+ it "includes includes `#{event_scope}` scope" do
+ expect(subject.find { |event| event['name'].include?(event_scope) }).not_to be_nil
+ end
+ end
+
+ it 'excludes some event types' do
+ expect(subject.find { |event| event['name'].include?("search_package") }).to be_nil
+ expect(subject.find { |event| event['name'].include?("list_package") }).to be_nil
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb
new file mode 100644
index 00000000000..6b7373cb3c7
--- /dev/null
+++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_execution_detector_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_execution_detector'
+
+RSpec.describe Tooling::Crystalball::CoverageLinesExecutionDetector do
+ subject(:detector) { described_class.new(root, exclude_prefixes: %w[vendor/ruby]) }
+
+ let(:root) { '/tmp' }
+ let(:before_map) { { path => { lines: [0, 2, nil] } } }
+ let(:after_map) { { path => { lines: [0, 3, nil] } } }
+ let(:path) { '/tmp/file.rb' }
+
+ describe '#detect' do
+ subject { detector.detect(before_map, after_map) }
+
+ it { is_expected.to eq(%w[file.rb]) }
+
+ context 'with no changes' do
+ let(:after_map) { { path => { lines: [0, 2, nil] } } }
+
+ it { is_expected.to eq([]) }
+ end
+
+ context 'with previously uncovered file' do
+ let(:before_map) { {} }
+
+ it { is_expected.to eq(%w[file.rb]) }
+ end
+
+ context 'with path outside of root' do
+ let(:path) { '/abc/file.rb' }
+
+ it { is_expected.to eq([]) }
+ end
+
+ context 'with path in excluded prefix' do
+ let(:path) { '/tmp/vendor/ruby/dependency.rb' }
+
+ it { is_expected.to eq([]) }
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb
new file mode 100644
index 00000000000..fd8fc4114a1
--- /dev/null
+++ b/spec/tooling/lib/tooling/crystalball/coverage_lines_strategy_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require_relative '../../../../../tooling/lib/tooling/crystalball/coverage_lines_strategy'
+
+RSpec.describe Tooling::Crystalball::CoverageLinesStrategy do
+ subject { described_class.new(execution_detector) }
+
+ let(:execution_detector) { instance_double('Tooling::Crystalball::CoverageLinesExecutionDetector') }
+
+ describe '#after_register' do
+ it 'starts coverage' do
+ expect(Coverage).to receive(:start).with(lines: true)
+ subject.after_register
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/test_file_finder_spec.rb b/spec/tooling/lib/tooling/test_file_finder_spec.rb
index 64b55b9b1d6..683bc647b8a 100644
--- a/spec/tooling/lib/tooling/test_file_finder_spec.rb
+++ b/spec/tooling/lib/tooling/test_file_finder_spec.rb
@@ -63,14 +63,6 @@ RSpec.describe Tooling::TestFileFinder do
end
end
- context 'when given a lib file in ee/' do
- let(:file) { 'ee/lib/flipper_session.rb' }
-
- it 'returns the matching ee/ lib test file' do
- expect(subject.test_files).to contain_exactly('ee/spec/lib/flipper_session_spec.rb')
- end
- end
-
context 'when given a test file in ee/' do
let(:file) { 'ee/spec/models/container_registry/event_spec.rb' }
diff --git a/spec/tooling/lib/tooling/test_map_generator_spec.rb b/spec/tooling/lib/tooling/test_map_generator_spec.rb
new file mode 100644
index 00000000000..7f3b2807162
--- /dev/null
+++ b/spec/tooling/lib/tooling/test_map_generator_spec.rb
@@ -0,0 +1,109 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/test_map_generator'
+
+RSpec.describe Tooling::TestMapGenerator do
+ subject { described_class.new }
+
+ describe '#parse' do
+ let(:yaml1) do
+ <<~YAML
+ ---
+ :type: Crystalball::ExecutionMap
+ :commit: a7d57d333042f3b0334b2f8a282354eef7365976
+ :timestamp: 1602668405
+ :version:
+ ---
+ "./spec/factories_spec.rb[1]":
+ - lib/gitlab/current_settings.rb
+ - lib/feature.rb
+ - lib/gitlab/marginalia.rb
+ YAML
+ end
+
+ let(:yaml2) do
+ <<~YAML
+ ---
+ :type: Crystalball::ExecutionMap
+ :commit: 74056e8d9cf3773f43faa1cf5416f8779c8284c8
+ :timestamp: 1602671965
+ :version:
+ ---
+ "./spec/models/project_spec.rb[1]":
+ - lib/gitlab/current_settings.rb
+ - lib/feature.rb
+ - lib/gitlab/marginalia.rb
+ YAML
+ end
+
+ let(:pathname) { instance_double(Pathname) }
+
+ before do
+ allow(File).to receive(:read).with('yaml1.yml').and_return(yaml1)
+ allow(File).to receive(:read).with('yaml2.yml').and_return(yaml2)
+ end
+
+ context 'with single yaml' do
+ let(:expected_mapping) do
+ {
+ 'lib/gitlab/current_settings.rb' => [
+ './spec/factories_spec.rb'
+ ],
+ 'lib/feature.rb' => [
+ './spec/factories_spec.rb'
+ ],
+ 'lib/gitlab/marginalia.rb' => [
+ './spec/factories_spec.rb'
+ ]
+ }
+ end
+
+ it 'parses crystalball data into test mapping' do
+ subject.parse('yaml1.yml')
+
+ expect(subject.mapping.keys).to match_array(expected_mapping.keys)
+ end
+
+ it 'stores test files without example uid' do
+ subject.parse('yaml1.yml')
+
+ expected_mapping.each do |file, tests|
+ expect(subject.mapping[file]).to match_array(tests)
+ end
+ end
+ end
+
+ context 'with multiple yamls' do
+ let(:expected_mapping) do
+ {
+ 'lib/gitlab/current_settings.rb' => [
+ './spec/factories_spec.rb',
+ './spec/models/project_spec.rb'
+ ],
+ 'lib/feature.rb' => [
+ './spec/factories_spec.rb',
+ './spec/models/project_spec.rb'
+ ],
+ 'lib/gitlab/marginalia.rb' => [
+ './spec/factories_spec.rb',
+ './spec/models/project_spec.rb'
+ ]
+ }
+ end
+
+ it 'parses crystalball data into test mapping' do
+ subject.parse(%w[yaml1.yml yaml2.yml])
+
+ expect(subject.mapping.keys).to match_array(expected_mapping.keys)
+ end
+
+ it 'stores test files without example uid' do
+ subject.parse(%w[yaml1.yml yaml2.yml])
+
+ expected_mapping.each do |file, tests|
+ expect(subject.mapping[file]).to match_array(tests)
+ end
+ end
+ end
+ end
+end
diff --git a/spec/tooling/lib/tooling/test_map_packer_spec.rb b/spec/tooling/lib/tooling/test_map_packer_spec.rb
new file mode 100644
index 00000000000..233134d2524
--- /dev/null
+++ b/spec/tooling/lib/tooling/test_map_packer_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require_relative '../../../../tooling/lib/tooling/test_map_packer'
+
+RSpec.describe Tooling::TestMapPacker do
+ subject { described_class.new }
+
+ let(:map) do
+ {
+ 'file1.rb' => [
+ './a/b/c/test_1.rb',
+ './a/b/test_2.rb',
+ './a/b/test_3.rb',
+ './a/test_4.rb',
+ './test_5.rb'
+ ],
+ 'file2.rb' => [
+ './a/b/c/test_1.rb',
+ './a/test_4.rb',
+ './test_5.rb'
+ ]
+ }
+ end
+
+ let(:compact_map) do
+ {
+ 'file1.rb' => {
+ '.' => {
+ 'a' => {
+ 'b' => {
+ 'c' => {
+ 'test_1.rb' => 1
+ },
+ 'test_2.rb' => 1,
+ 'test_3.rb' => 1
+ },
+ 'test_4.rb' => 1
+ },
+ 'test_5.rb' => 1
+ }
+ },
+ 'file2.rb' => {
+ '.' => {
+ 'a' => {
+ 'b' => {
+ 'c' => {
+ 'test_1.rb' => 1
+ }
+ },
+ 'test_4.rb' => 1
+ },
+ 'test_5.rb' => 1
+ }
+ }
+ }
+ end
+
+ describe '#pack' do
+ it 'compacts list of test files into a prefix tree' do
+ expect(subject.pack(map)).to eq(compact_map)
+ end
+
+ it 'does nothing to empty hash' do
+ expect(subject.pack({})).to eq({})
+ end
+ end
+
+ describe '#unpack' do
+ it 'unpack prefix tree into list of test files' do
+ expect(subject.unpack(compact_map)).to eq(map)
+ end
+
+ it 'does nothing to empty hash' do
+ expect(subject.unpack({})).to eq({})
+ end
+ end
+end
diff --git a/spec/uploaders/dependency_proxy/file_uploader_spec.rb b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
new file mode 100644
index 00000000000..724a9c42f47
--- /dev/null
+++ b/spec/uploaders/dependency_proxy/file_uploader_spec.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+require 'spec_helper'
+
+RSpec.describe DependencyProxy::FileUploader do
+ let(:blob) { create(:dependency_proxy_blob) }
+ let(:uploader) { described_class.new(blob, :file) }
+ let(:path) { Gitlab.config.dependency_proxy.storage_path }
+
+ subject { uploader }
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}],
+ cache_dir: %r[/dependency_proxy/tmp/cache],
+ work_dir: %r[/dependency_proxy/tmp/work]
+
+ context 'object store is remote' do
+ before do
+ stub_dependency_proxy_object_storage
+ end
+
+ include_context 'with storage', described_class::Store::REMOTE
+
+ it_behaves_like "builds correct paths",
+ store_dir: %r[\h{2}/\h{2}]
+ end
+end
diff --git a/spec/uploaders/gitlab_uploader_spec.rb b/spec/uploaders/gitlab_uploader_spec.rb
index 72845b47a53..4e661e458ad 100644
--- a/spec/uploaders/gitlab_uploader_spec.rb
+++ b/spec/uploaders/gitlab_uploader_spec.rb
@@ -141,5 +141,23 @@ RSpec.describe GitlabUploader do
end
end
end
+
+ describe '#url_or_file_path' do
+ let(:options) { { expire_at: 1.day.from_now } }
+
+ it 'returns url when in remote storage' do
+ expect(subject).to receive(:file_storage?).and_return(false)
+ expect(subject).to receive(:url).with(options).and_return("http://example.com")
+
+ expect(subject.url_or_file_path(options)).to eq("http://example.com")
+ end
+
+ it 'returns url when in remote storage' do
+ expect(subject).to receive(:file_storage?).and_return(true)
+ expect(subject).to receive(:path).and_return("/tmp/file")
+
+ expect(subject.url_or_file_path(options)).to eq("file:///tmp/file")
+ end
+ end
end
end
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 ef5459ce788..fd01a18e810 100644
--- a/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
+++ b/spec/uploaders/workers/object_storage/migrate_uploads_worker_spec.rb
@@ -13,8 +13,103 @@ RSpec.describe ObjectStorage::MigrateUploadsWorker do
# swallow
end
+ # Expects the calling spec to define:
+ # - model_class
+ # - mounted_as
+ # - to_store
+ RSpec.shared_examples 'uploads migration worker' do
+ describe '.enqueue!' do
+ def enqueue!
+ described_class.enqueue!(uploads, model_class, mounted_as, to_store)
+ end
+
+ it 'is guarded by .sanity_check!' do
+ expect(described_class).to receive(:perform_async)
+ expect(described_class).to receive(:sanity_check!)
+
+ enqueue!
+ end
+
+ context 'sanity_check! fails' do
+ before do
+ expect(described_class).to receive(:sanity_check!).and_raise(described_class::SanityCheckError)
+ end
+
+ it 'does not enqueue a job' do
+ expect(described_class).not_to receive(:perform_async)
+
+ expect { enqueue! }.to raise_error(described_class::SanityCheckError)
+ end
+ end
+ end
+
+ describe '.sanity_check!' do
+ shared_examples 'raises a SanityCheckError' do |expected_message|
+ let(:mount_point) { nil }
+
+ it do
+ expect { described_class.sanity_check!(uploads, model_class, mount_point) }
+ .to raise_error(described_class::SanityCheckError).with_message(expected_message)
+ end
+ end
+
+ context 'uploader types mismatch' do
+ let!(:outlier) { create(:upload, uploader: 'GitlabUploader') }
+
+ include_examples 'raises a SanityCheckError', /Multiple uploaders found/
+ end
+
+ context 'mount point not found' do
+ include_examples 'raises a SanityCheckError', /Mount point [a-z:]+ not found in/ do
+ let(:mount_point) { :potato }
+ end
+ end
+ end
+
+ describe '#perform' do
+ it 'migrates files to remote storage' do
+ expect(Gitlab::AppLogger).to receive(:info).with(%r{Migrated 1/1 files})
+
+ perform(uploads)
+
+ expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(0)
+ end
+
+ context 'reversed' do
+ let(:to_store) { ObjectStorage::Store::LOCAL }
+
+ before do
+ perform(uploads, ObjectStorage::Store::REMOTE)
+ end
+
+ it 'migrates files to local storage' do
+ expect(Upload.where(store: ObjectStorage::Store::REMOTE).count).to eq(1)
+
+ perform(uploads)
+
+ expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(1)
+ end
+ end
+
+ context 'migration is unsuccessful' do
+ before do
+ allow_any_instance_of(ObjectStorage::Concern)
+ .to receive(:migrate!).and_raise(CarrierWave::UploadError, 'I am a teapot.')
+ end
+
+ it 'does not migrate files to remote storage' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(/Error .* I am a teapot/)
+
+ perform(uploads)
+
+ expect(Upload.where(store: ObjectStorage::Store::LOCAL).count).to eq(1)
+ end
+ end
+ end
+ end
+
context "for AvatarUploader" do
- let!(:projects) { create_list(:project, 10, :with_avatar) }
+ let!(:project_with_avatar) { create(:project, :with_avatar) }
let(:mounted_as) { :avatar }
before do
@@ -27,16 +122,15 @@ RSpec.describe ObjectStorage::MigrateUploadsWorker do
it "to N*5" do
query_count = ActiveRecord::QueryRecorder.new { perform(uploads) }
- more_projects = create_list(:project, 3, :with_avatar)
+ create(:project, :with_avatar)
- expected_queries_per_migration = 5 * more_projects.count
- expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(expected_queries_per_migration)
+ expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(5)
end
end
end
context "for FileUploader" do
- let!(:projects) { create_list(:project, 10) }
+ let!(:project_with_file) { create(:project) }
let(:secret) { SecureRandom.hex }
let(:mounted_as) { nil }
@@ -48,7 +142,7 @@ RSpec.describe ObjectStorage::MigrateUploadsWorker do
before do
stub_uploads_object_storage(FileUploader)
- projects.map(&method(:upload_file))
+ upload_file(project_with_file)
end
it_behaves_like "uploads migration worker"
@@ -57,18 +151,16 @@ RSpec.describe ObjectStorage::MigrateUploadsWorker do
it "to N*5" do
query_count = ActiveRecord::QueryRecorder.new { perform(uploads) }
- more_projects = create_list(:project, 3)
- more_projects.map(&method(:upload_file))
+ upload_file(create(:project))
- expected_queries_per_migration = 5 * more_projects.count
- expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(expected_queries_per_migration)
+ expect { perform(Upload.all) }.not_to exceed_query_limit(query_count).with_threshold(5)
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!(:design_action) { create(:design_action, :with_image_v432x230) }
let(:mounted_as) { :image_v432x230 }
before do
diff --git a/spec/validators/rsa_key_validator_spec.rb b/spec/validators/rsa_key_validator_spec.rb
new file mode 100644
index 00000000000..b4e74ec5605
--- /dev/null
+++ b/spec/validators/rsa_key_validator_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe RsaKeyValidator do
+ let(:validatable) do
+ Class.new do
+ include ActiveModel::Validations
+
+ attr_accessor :signing_key
+
+ validates :signing_key, rsa_key: true
+
+ def initialize(signing_key)
+ @signing_key = signing_key
+ end
+ end
+ end
+
+ subject(:validator) { described_class.new(attributes: [:signing_key]) }
+
+ it 'is not valid when invalid RSA key is provided' do
+ record = validatable.new('invalid RSA key')
+
+ validator.validate(record)
+
+ aggregate_failures do
+ expect(record).not_to be_valid
+ expect(record.errors[:signing_key]).to include('is not a valid RSA key')
+ end
+ end
+
+ it 'is valid when valid RSA key is provided' do
+ record = validatable.new(OpenSSL::PKey::RSA.new(1024).to_pem)
+
+ validator.validate(record)
+
+ expect(record).to be_valid
+ end
+end
diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
index 3fb9fb54b01..c5b56b15431 100644
--- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb
@@ -219,6 +219,22 @@ RSpec.describe 'layouts/nav/sidebar/_project' do
end
end
+ describe 'pipeline editor link' do
+ it 'shows the pipeline editor link' do
+ render
+
+ expect(rendered).to have_link('Editor', href: project_ci_pipeline_editor_path(project))
+ end
+
+ it 'does not show the pipeline editor link' do
+ allow(view).to receive(:can_view_pipeline_editor?).and_return(false)
+
+ render
+
+ expect(rendered).not_to have_link('Editor', href: project_ci_pipeline_editor_path(project))
+ end
+ end
+
describe 'operations settings tab' do
describe 'archive projects' do
before do
diff --git a/spec/views/profiles/preferences/show.html.haml_spec.rb b/spec/views/profiles/preferences/show.html.haml_spec.rb
index aab50209953..2fe941b9f14 100644
--- a/spec/views/profiles/preferences/show.html.haml_spec.rb
+++ b/spec/views/profiles/preferences/show.html.haml_spec.rb
@@ -68,61 +68,4 @@ RSpec.describe 'profiles/preferences/show' do
expect(rendered).to have_css('#localization')
end
end
-
- context 'sourcegraph' do
- def have_sourcegraph_field(*args)
- have_field('user_sourcegraph_enabled', *args)
- end
-
- def have_integrations_section
- have_css('#integrations.profile-settings-sidebar', text: 'Integrations')
- end
-
- before do
- stub_feature_flags(sourcegraph: sourcegraph_feature)
- stub_application_setting(sourcegraph_enabled: sourcegraph_enabled)
- end
-
- context 'when not fully enabled' do
- where(:feature, :admin_enabled) do
- false | false
- false | true
- true | false
- end
-
- with_them do
- let(:sourcegraph_feature) { feature }
- let(:sourcegraph_enabled) { admin_enabled }
-
- before do
- render
- end
-
- it 'does not display sourcegraph field' do
- expect(rendered).not_to have_sourcegraph_field
- end
-
- it 'does not display Integration Settings' do
- expect(rendered).not_to have_integrations_section
- end
- end
- end
-
- context 'when fully enabled' do
- let(:sourcegraph_feature) { true }
- let(:sourcegraph_enabled) { true }
-
- before do
- render
- end
-
- it 'displays the sourcegraph field' do
- expect(rendered).to have_sourcegraph_field
- end
-
- it 'displays the integrations section' do
- expect(rendered).to have_integrations_section
- end
- end
- end
end
diff --git a/spec/views/projects/ci/lints/show.html.haml_spec.rb b/spec/views/projects/ci/lints/show.html.haml_spec.rb
deleted file mode 100644
index f59ad3f5f84..00000000000
--- a/spec/views/projects/ci/lints/show.html.haml_spec.rb
+++ /dev/null
@@ -1,127 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe 'projects/ci/lints/show' do
- include Devise::Test::ControllerHelpers
- let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project, :repository) }
- let(:lint) { Gitlab::Ci::Lint.new(project: project, current_user: user) }
- let(:result) { lint.validate(YAML.dump(content)) }
-
- describe 'XSS protection' do
- before do
- assign(:project, project)
- assign(:result, result)
- stub_feature_flags(ci_lint_vue: false)
- end
-
- context 'when builds attrbiutes contain HTML nodes' do
- let(:content) do
- {
- rspec: {
- script: '<h1>rspec</h1>',
- stage: 'test'
- }
- }
- end
-
- it 'does not render HTML elements' do
- render
-
- expect(rendered).not_to have_css('h1', text: 'rspec')
- end
- end
-
- context 'when builds attributes do not contain HTML nodes' do
- let(:content) do
- {
- rspec: {
- script: 'rspec',
- stage: 'test'
- }
- }
- end
-
- it 'shows configuration in the table' do
- render
-
- expect(rendered).to have_css('td pre', text: 'rspec')
- end
- end
- end
-
- context 'when the content is valid' do
- let(:content) do
- {
- build_template: {
- script: './build.sh',
- tags: ['dotnet'],
- only: ['test@dude/repo'],
- except: ['deploy'],
- environment: 'testing'
- }
- }
- end
-
- before do
- assign(:project, project)
- assign(:result, result)
- stub_feature_flags(ci_lint_vue: false)
- end
-
- it 'shows the correct values' do
- render
-
- expect(rendered).to have_content('Status: syntax is correct')
- expect(rendered).to have_content('Tag list: dotnet')
- expect(rendered).to have_content('Only policy: refs, test@dude/repo')
- expect(rendered).to have_content('Except policy: refs, deploy')
- expect(rendered).to have_content('Environment: testing')
- expect(rendered).to have_content('When: on_success')
- end
-
- context 'when content has warnings' do
- before do
- allow(result).to receive(:warnings).and_return(['Warning 1', 'Warning 2'])
- end
-
- it 'shows warning messages' do
- render
-
- expect(rendered).to have_content('2 warning(s) found:')
- expect(rendered).to have_content('Warning 1')
- expect(rendered).to have_content('Warning 2')
- end
- end
- end
-
- context 'when the content is invalid' do
- let(:content) { double(:content) }
-
- before do
- allow(result).to receive(:warnings).and_return(['Warning 1', 'Warning 2'])
- allow(result).to receive(:errors).and_return(['Undefined error'])
-
- assign(:project, project)
- assign(:result, result)
- stub_feature_flags(ci_lint_vue: false)
- end
-
- it 'shows error message' do
- render
-
- expect(rendered).to have_content('Status: syntax is incorrect')
- expect(rendered).to have_content('Undefined error')
- expect(rendered).not_to have_content('Tag list:')
- end
-
- it 'shows warning messages' do
- render
-
- expect(rendered).to have_content('2 warning(s) found:')
- expect(rendered).to have_content('Warning 1')
- expect(rendered).to have_content('Warning 2')
- 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 1acc07dabb6..9b4f2774c5b 100644
--- a/spec/views/projects/merge_requests/show.html.haml_spec.rb
+++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe 'projects/merge_requests/show.html.haml' do
render
- expect(rendered).not_to have_css('.cannot-be-merged')
+ expect(rendered).not_to have_css('.merge-icon')
end
end
end
diff --git a/spec/views/projects/settings/operations/show.html.haml_spec.rb b/spec/views/projects/settings/operations/show.html.haml_spec.rb
index 24ab64b20f5..facb4e2016d 100644
--- a/spec/views/projects/settings/operations/show.html.haml_spec.rb
+++ b/spec/views/projects/settings/operations/show.html.haml_spec.rb
@@ -25,7 +25,7 @@ RSpec.describe 'projects/settings/operations/show' do
end
before_all do
- project.add_reporter(user)
+ project.add_maintainer(user)
end
before do
diff --git a/spec/views/projects/tags/index.html.haml_spec.rb b/spec/views/projects/tags/index.html.haml_spec.rb
index 4d501b82238..8cc86b75873 100644
--- a/spec/views/projects/tags/index.html.haml_spec.rb
+++ b/spec/views/projects/tags/index.html.haml_spec.rb
@@ -57,4 +57,29 @@ RSpec.describe 'projects/tags/index.html.haml' do
expect(rendered).not_to have_link(href: latest_succeeded_project_artifacts_path(project, "#{pipeline.ref}/download", job: 'test'))
end
end
+
+ context 'build stats' do
+ let(:tag) { 'v1.0.0' }
+ let(:page) { Capybara::Node::Simple.new(rendered) }
+
+ it 'shows build status or placeholder when pipelines present' do
+ create(:ci_pipeline,
+ project: project,
+ ref: tag,
+ sha: project.commit(tag).sha,
+ status: :success)
+ assign(:tag_pipeline_statuses, Ci::CommitStatusesFinder.new(project, project.repository, project.namespace.owner, tags).execute)
+
+ render
+
+ expect(page.find('.tags .content-list li', text: tag)).to have_css 'a.ci-status-icon-success'
+ expect(page.all('.tags .content-list li')).to all(have_css('svg.s24'))
+ end
+
+ it 'shows no build status or placeholder when no pipelines present' do
+ render
+
+ expect(page.all('.tags .content-list li')).not_to have_css 'svg.s24'
+ end
+ end
end
diff --git a/spec/views/registrations/welcome.html.haml_spec.rb b/spec/views/registrations/welcome/show.html.haml_spec.rb
index 56a7784a134..bede52bed4b 100644
--- a/spec/views/registrations/welcome.html.haml_spec.rb
+++ b/spec/views/registrations/welcome/show.html.haml_spec.rb
@@ -2,7 +2,7 @@
require 'spec_helper'
-RSpec.describe 'registrations/welcome' do
+RSpec.describe 'registrations/welcome/show' do
using RSpec::Parameterized::TableSyntax
let_it_be(:user) { User.new }
diff --git a/spec/views/search/_filter.html.haml_spec.rb b/spec/views/search/_filter.html.haml_spec.rb
index eb32528e3c7..9a5ff2e4518 100644
--- a/spec/views/search/_filter.html.haml_spec.rb
+++ b/spec/views/search/_filter.html.haml_spec.rb
@@ -8,7 +8,7 @@ RSpec.describe 'search/_filter' do
render
expect(rendered).to have_selector('label[for="dashboard_search_group"]')
- expect(rendered).to have_selector('button#dashboard_search_group')
+ expect(rendered).to have_selector('input#js-search-group-dropdown')
expect(rendered).to have_selector('label[for="dashboard_search_project"]')
expect(rendered).to have_selector('button#dashboard_search_project')
diff --git a/spec/views/search/_results.html.haml_spec.rb b/spec/views/search/_results.html.haml_spec.rb
index 033b2304e33..6299fd0cf36 100644
--- a/spec/views/search/_results.html.haml_spec.rb
+++ b/spec/views/search/_results.html.haml_spec.rb
@@ -43,7 +43,7 @@ RSpec.describe 'search/_results' do
let_it_be(:wiki_blob) { create(:wiki_page, project: project, content: '*') }
let_it_be(:user) { create(:admin) }
- %w[issues blobs notes wiki_blobs merge_requests milestones].each do |search_scope|
+ %w[issues merge_requests].each do |search_scope|
context "when scope is #{search_scope}" do
let(:scope) { search_scope }
let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
@@ -55,32 +55,30 @@ RSpec.describe 'search/_results' do
expect(rendered).to have_selector('[data-track-property=search_result]')
end
- it 'renders the state filter drop down' do
+ it 'does render the sidebar' do
render
- expect(rendered).to have_selector('#js-search-filter-by-state')
+ expect(rendered).to have_selector('#js-search-sidebar')
end
+ end
+ end
- context 'Feature search_filter_by_confidential' do
- context 'when disabled' do
- before do
- stub_feature_flags(search_filter_by_confidential: false)
- end
+ %w[blobs notes wiki_blobs milestones].each do |search_scope|
+ context "when scope is #{search_scope}" do
+ let(:scope) { search_scope }
+ let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
- it 'does not render the confidential drop down' do
- render
+ it 'renders the click text event tracking attributes' do
+ render
- expect(rendered).not_to have_selector('#js-search-filter-by-confidential')
- end
- end
+ expect(rendered).to have_selector('[data-track-event=click_text]')
+ expect(rendered).to have_selector('[data-track-property=search_result]')
+ end
- context 'when enabled' do
- it 'renders the confidential drop down' do
- render
+ it 'does not render the sidebar' do
+ render
- expect(rendered).to have_selector('#js-search-filter-by-confidential')
- end
- end
+ expect(rendered).not_to have_selector('#js-search-sidebar')
end
end
end
diff --git a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
index ff692d0eda6..c7de8553d86 100644
--- a/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
+++ b/spec/workers/analytics/instance_statistics/count_job_trigger_worker_spec.rb
@@ -6,7 +6,7 @@ RSpec.describe Analytics::InstanceStatistics::CountJobTriggerWorker do
it_behaves_like 'an idempotent worker'
context 'triggers a job for each measurement identifiers' do
- let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifiers.size }
+ let(:expected_count) { Analytics::InstanceStatistics::Measurement.identifier_query_mapping.keys.size }
it 'triggers CounterJobWorker jobs' do
subject.perform
diff --git a/spec/workers/background_migration_worker_spec.rb b/spec/workers/background_migration_worker_spec.rb
index 15e93d62c7d..8094efcaf04 100644
--- a/spec/workers/background_migration_worker_spec.rb
+++ b/spec/workers/background_migration_worker_spec.rb
@@ -12,45 +12,91 @@ RSpec.describe BackgroundMigrationWorker, :clean_gitlab_redis_shared_state do
end
describe '#perform' do
- it 'performs a background migration' do
- expect(Gitlab::BackgroundMigration)
- .to receive(:perform)
- .with('Foo', [10, 20])
+ before do
+ allow(worker).to receive(:jid).and_return(1)
+ expect(worker).to receive(:always_perform?).and_return(false)
+ end
- worker.perform('Foo', [10, 20])
+ context 'when lease can be obtained' do
+ before do
+ expect(Gitlab::BackgroundMigration)
+ .to receive(:perform)
+ .with('Foo', [10, 20])
+ end
+
+ it 'performs a background migration' do
+ worker.perform('Foo', [10, 20])
+ end
+
+ context 'when lease_attempts is 1' do
+ it 'performs a background migration' do
+ worker.perform('Foo', [10, 20], 1)
+ end
+ end
end
- it 'reschedules a migration if it was performed recently' do
- expect(worker)
- .to receive(:always_perform?)
- .and_return(false)
+ context 'when lease not obtained (migration of same class was performed recently)' do
+ before do
+ expect(Gitlab::BackgroundMigration).not_to receive(:perform)
+
+ worker.lease_for('Foo').try_obtain
+ end
- worker.lease_for('Foo').try_obtain
+ it 'reschedules the migration and decrements the lease_attempts' do
+ expect(described_class)
+ .to receive(:perform_in)
+ .with(a_kind_of(Numeric), 'Foo', [10, 20], 4)
- expect(Gitlab::BackgroundMigration)
- .not_to receive(:perform)
+ worker.perform('Foo', [10, 20], 5)
+ end
- expect(described_class)
- .to receive(:perform_in)
- .with(a_kind_of(Numeric), 'Foo', [10, 20])
+ context 'when lease_attempts is 1' do
+ it 'reschedules the migration and decrements the lease_attempts' do
+ expect(described_class)
+ .to receive(:perform_in)
+ .with(a_kind_of(Numeric), 'Foo', [10, 20], 0)
- worker.perform('Foo', [10, 20])
+ worker.perform('Foo', [10, 20], 1)
+ end
+ end
+
+ context 'when lease_attempts is 0' do
+ it 'gives up performing the migration' do
+ expect(described_class).not_to receive(:perform_in)
+ expect(Sidekiq.logger).to receive(:warn).with(
+ class: 'Foo',
+ message: 'Job could not get an exclusive lease after several tries. Giving up.',
+ job_id: 1)
+
+ worker.perform('Foo', [10, 20], 0)
+ end
+ end
end
- it 'reschedules a migration if the database is not healthy' do
- allow(worker)
- .to receive(:always_perform?)
- .and_return(false)
+ context 'when database is not healthy' do
+ before do
+ allow(worker).to receive(:healthy_database?).and_return(false)
+ end
- allow(worker)
- .to receive(:healthy_database?)
- .and_return(false)
+ it 'reschedules a migration if the database is not healthy' do
+ expect(described_class)
+ .to receive(:perform_in)
+ .with(a_kind_of(Numeric), 'Foo', [10, 20], 4)
- expect(described_class)
- .to receive(:perform_in)
- .with(a_kind_of(Numeric), 'Foo', [10, 20])
+ worker.perform('Foo', [10, 20])
+ end
- worker.perform('Foo', [10, 20])
+ context 'when lease_attempts is 0' do
+ it 'gives up performing the migration' do
+ expect(described_class).not_to receive(:perform_in)
+ expect(Sidekiq.logger).to receive(:warn).with(
+ class: 'Foo',
+ message: 'Database was unhealthy after several tries. Giving up.',
+ job_id: 1)
+
+ worker.perform('Foo', [10, 20], 0)
+ end
+ end
end
it 'sets the class that will be executed as the caller_id' do
diff --git a/spec/workers/build_finished_worker_spec.rb b/spec/workers/build_finished_worker_spec.rb
index 11b50961e9e..b0058c76e27 100644
--- a/spec/workers/build_finished_worker_spec.rb
+++ b/spec/workers/build_finished_worker_spec.rb
@@ -11,18 +11,28 @@ RSpec.describe BuildFinishedWorker do
context 'when build exists' do
let!(:build) { create(:ci_build) }
- it 'calculates coverage and calls hooks' do
- expect(BuildTraceSectionsWorker)
- .to receive(:new).ordered.and_call_original
- expect(BuildCoverageWorker)
- .to receive(:new).ordered.and_call_original
-
- expect_any_instance_of(BuildTraceSectionsWorker).to receive(:perform)
- expect_any_instance_of(BuildCoverageWorker).to receive(:perform)
+ it 'calculates coverage and calls hooks', :aggregate_failures do
+ trace_worker = double('trace worker')
+ coverage_worker = double('coverage worker')
+
+ allow(BuildTraceSectionsWorker).to receive(:new).and_return(trace_worker)
+ allow(BuildCoverageWorker).to receive(:new).and_return(coverage_worker)
+
+ # Unfortunately, `ordered` does not seem to work when called within `allow_next_instance_of`
+ # so we're doing this the long and dirty way
+ expect(trace_worker).to receive(:perform).ordered
+ expect(coverage_worker).to receive(:perform).ordered
+
+ expect_next_instance_of(Ci::BuildReportResultWorker) do |instance|
+ expect(instance).to receive(:perform)
+ end
+ expect_next_instance_of(Ci::TestCasesService) do |instance|
+ expect(instance).to receive(:execute)
+ end
+
expect(BuildHooksWorker).to receive(:perform_async)
expect(ExpirePipelineCacheWorker).to receive(:perform_async)
expect(ChatNotificationWorker).not_to receive(:perform_async)
- expect(Ci::BuildReportResultWorker).not_to receive(:perform)
expect(ArchiveTraceWorker).to receive(:perform_in)
subject
@@ -31,7 +41,7 @@ RSpec.describe BuildFinishedWorker do
context 'when build does not exist' do
it 'does not raise exception' do
- expect { described_class.new.perform(123) }
+ expect { described_class.new.perform(non_existing_record_id) }
.not_to raise_error
end
end
@@ -45,17 +55,5 @@ RSpec.describe BuildFinishedWorker do
subject
end
end
-
- context 'when build has a test report' do
- let(:build) { create(:ci_build, :test_reports) }
-
- it 'schedules a BuildReportResult job' do
- expect_next_instance_of(Ci::BuildReportResultWorker) do |worker|
- expect(worker).to receive(:perform).with(build.id)
- end
-
- subject
- end
- end
end
end
diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb
new file mode 100644
index 00000000000..12783f40528
--- /dev/null
+++ b/spec/workers/bulk_import_worker_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe BulkImportWorker do
+ describe '#perform' do
+ it 'executes Group Importer' do
+ bulk_import_id = 1
+
+ expect(BulkImports::Importers::GroupsImporter)
+ .to receive(:new).with(bulk_import_id).and_return(double(execute: true))
+
+ described_class.new.perform(bulk_import_id)
+ end
+ end
+end
diff --git a/spec/workers/ci/delete_objects_worker_spec.rb b/spec/workers/ci/delete_objects_worker_spec.rb
index 6cb8e0cba37..52d90d7667a 100644
--- a/spec/workers/ci/delete_objects_worker_spec.rb
+++ b/spec/workers/ci/delete_objects_worker_spec.rb
@@ -9,9 +9,14 @@ RSpec.describe Ci::DeleteObjectsWorker do
describe '#perform' do
it 'executes a service' do
+ allow(worker).to receive(:max_running_jobs).and_return(25)
+
expect_next_instance_of(Ci::DeleteObjectsService) do |instance|
expect(instance).to receive(:execute)
- expect(instance).to receive(:remaining_batches_count).once.and_call_original
+ expect(instance).to receive(:remaining_batches_count)
+ .with(max_batch_count: 25)
+ .once
+ .and_call_original
end
worker.perform
@@ -23,7 +28,6 @@ RSpec.describe Ci::DeleteObjectsWorker do
before do
stub_feature_flags(
- ci_delete_objects_low_concurrency: low,
ci_delete_objects_medium_concurrency: medium,
ci_delete_objects_high_concurrency: high
)
@@ -31,13 +35,11 @@ RSpec.describe Ci::DeleteObjectsWorker do
subject(:max_running_jobs) { worker.max_running_jobs }
- where(:low, :medium, :high, :expected) do
- false | false | false | 0
- true | true | true | 2
- true | false | false | 2
- false | true | false | 20
- false | true | true | 20
- false | false | true | 50
+ where(:medium, :high, :expected) do
+ false | false | 2
+ true | false | 20
+ true | true | 20
+ false | true | 50
end
with_them do
diff --git a/spec/workers/concerns/application_worker_spec.rb b/spec/workers/concerns/application_worker_spec.rb
index a18b83f199b..07e11f014c3 100644
--- a/spec/workers/concerns/application_worker_spec.rb
+++ b/spec/workers/concerns/application_worker_spec.rb
@@ -45,7 +45,7 @@ RSpec.describe ApplicationWorker do
instance.jid = 'a jid'
expect(result).to include(
- 'class' => worker.class,
+ 'class' => instance.class.name,
'job_status' => 'running',
'queue' => worker.queue,
'jid' => instance.jid
@@ -69,7 +69,7 @@ RSpec.describe ApplicationWorker do
it 'does not override predefined context keys with custom payload' do
payload['class'] = 'custom value'
- expect(result).to include('class' => worker.class)
+ expect(result).to include('class' => instance.class.name)
end
end
diff --git a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
index 09d64fe50bd..8727756ce50 100644
--- a/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/rescheduling_methods_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe Gitlab::GithubImport::ReschedulingMethods do
end
context 'with an existing project' do
- let(:project) { create(:project) }
+ let(:project) { create(:project, import_url: 'https://t0ken@github.com/repo/repo.git') }
it 'notifies any waiters upon successfully importing the data' do
expect(worker)
diff --git a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
index b7635748498..03e875bcb87 100644
--- a/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
+++ b/spec/workers/concerns/gitlab/github_import/stage_methods_spec.rb
@@ -9,6 +9,8 @@ RSpec.describe Gitlab::GithubImport::StageMethods do
end
describe '#perform' do
+ let(:project) { create(:project, import_url: 'https://t0ken@github.com/repo/repo.git') }
+
it 'returns if no project could be found' do
expect(worker).not_to receive(:try_import)
diff --git a/spec/workers/concerns/limited_capacity/worker_spec.rb b/spec/workers/concerns/limited_capacity/worker_spec.rb
index 8a15675c04d..2c33c8666ec 100644
--- a/spec/workers/concerns/limited_capacity/worker_spec.rb
+++ b/spec/workers/concerns/limited_capacity/worker_spec.rb
@@ -121,7 +121,8 @@ RSpec.describe LimitedCapacity::Worker, :clean_gitlab_redis_queues, :aggregate_f
it 'reports prometheus metrics' do
allow(worker).to receive(:perform_work)
- expect(worker).to receive(:report_prometheus_metrics)
+ expect(worker).to receive(:report_prometheus_metrics).once.and_call_original
+ expect(worker).to receive(:report_running_jobs_metrics).twice.and_call_original
perform
end
diff --git a/spec/workers/concerns/reenqueuer_spec.rb b/spec/workers/concerns/reenqueuer_spec.rb
index df0724045c1..ab44042834f 100644
--- a/spec/workers/concerns/reenqueuer_spec.rb
+++ b/spec/workers/concerns/reenqueuer_spec.rb
@@ -40,9 +40,7 @@ RSpec.describe Reenqueuer do
it_behaves_like 'reenqueuer'
- it_behaves_like 'it is rate limited to 1 call per', 5.seconds do
- let(:rate_limited_method) { subject.perform }
- end
+ it_behaves_like '#perform is rate limited to 1 call per', 5.seconds
it 'disables Sidekiq retries' do
expect(job.sidekiq_options_hash).to include('retry' => false)
@@ -98,7 +96,7 @@ RSpec.describe Reenqueuer::ReenqueuerSleeper do
Class.new do
include Reenqueuer::ReenqueuerSleeper
- def rate_limited_method
+ def perform
ensure_minimum_duration(11.seconds) do
# do work
end
@@ -108,12 +106,11 @@ RSpec.describe Reenqueuer::ReenqueuerSleeper do
subject(:dummy) { dummy_class.new }
- # Test that rate_limited_method is rate limited by ensure_minimum_duration
- it_behaves_like 'it is rate limited to 1 call per', 11.seconds do
- let(:rate_limited_method) { dummy.rate_limited_method }
- end
+ # Slightly higher-level test of ensure_minimum_duration since we conveniently
+ # already have this shared example anyway.
+ it_behaves_like '#perform is rate limited to 1 call per', 11.seconds
- # Test ensure_minimum_duration more directly
+ # Unit test ensure_minimum_duration
describe '#ensure_minimum_duration' do
around do |example|
# Allow Timecop.travel without the block form
diff --git a/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
new file mode 100644
index 00000000000..d98ea1b6ab2
--- /dev/null
+++ b/spec/workers/container_expiration_policies/cleanup_container_repository_worker_spec.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ContainerExpirationPolicies::CleanupContainerRepositoryWorker do
+ let_it_be(:repository, reload: true) { create(:container_repository, :cleanup_scheduled) }
+ let_it_be(:project) { repository.project }
+ let_it_be(:policy) { project.container_expiration_policy }
+ let_it_be(:other_repository) { create(:container_repository) }
+
+ let(:worker) { described_class.new }
+
+ describe '#perform_work' do
+ subject { worker.perform_work }
+
+ before do
+ policy.update_column(:enabled, true)
+ end
+
+ RSpec.shared_examples 'handling all repository conditions' do
+ it 'sends the repository for cleaning' do
+ expect(ContainerExpirationPolicies::CleanupService)
+ .to receive(:new).with(repository).and_return(double(execute: cleanup_service_response(repository: repository)))
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, :finished)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id)
+
+ subject
+ end
+
+ context 'with unfinished cleanup' do
+ it 'logs an unfinished cleanup' do
+ expect(ContainerExpirationPolicies::CleanupService)
+ .to receive(:new).with(repository).and_return(double(execute: cleanup_service_response(status: :unfinished, repository: repository)))
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, :unfinished)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id)
+
+ subject
+ end
+ end
+
+ context 'with policy running shortly' do
+ before do
+ repository.project
+ .container_expiration_policy
+ .update_column(:next_run_at, 1.minute.from_now)
+ end
+
+ it 'skips the repository' do
+ expect(ContainerExpirationPolicies::CleanupService).not_to receive(:new)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, :skipped)
+
+ expect { subject }.to change { ContainerRepository.waiting_for_cleanup.count }.from(1).to(0)
+ expect(repository.reload.cleanup_unscheduled?).to be_truthy
+ end
+ end
+
+ context 'with disabled policy' do
+ before do
+ repository.project
+ .container_expiration_policy
+ .disable!
+ end
+
+ it 'skips the repository' do
+ expect(ContainerExpirationPolicies::CleanupService).not_to receive(:new)
+
+ expect { subject }.to change { ContainerRepository.waiting_for_cleanup.count }.from(1).to(0)
+ expect(repository.reload.cleanup_unscheduled?).to be_truthy
+ end
+ end
+ end
+
+ context 'with repository in cleanup scheduled state' do
+ it_behaves_like 'handling all repository conditions'
+ end
+
+ context 'with repository in cleanup unfinished state' do
+ before do
+ repository.cleanup_unfinished!
+ end
+
+ it_behaves_like 'handling all repository conditions'
+ end
+
+ context 'with another repository in cleanup unfinished state' do
+ let_it_be(:another_repository) { create(:container_repository, :cleanup_unfinished) }
+
+ it 'process the cleanup scheduled repository first' do
+ expect(ContainerExpirationPolicies::CleanupService)
+ .to receive(:new).with(repository).and_return(double(execute: cleanup_service_response(repository: repository)))
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, :finished)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id)
+
+ subject
+ end
+ end
+
+ context 'with multiple repositories in cleanup unfinished state' do
+ let_it_be(:repository2) { create(:container_repository, :cleanup_unfinished, expiration_policy_started_at: 20.minutes.ago) }
+ let_it_be(:repository3) { create(:container_repository, :cleanup_unfinished, expiration_policy_started_at: 10.minutes.ago) }
+
+ before do
+ repository.update!(expiration_policy_cleanup_status: :cleanup_unfinished, expiration_policy_started_at: 30.minutes.ago)
+ end
+
+ it 'process the repository with the oldest expiration_policy_started_at' do
+ expect(ContainerExpirationPolicies::CleanupService)
+ .to receive(:new).with(repository).and_return(double(execute: cleanup_service_response(repository: repository)))
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:cleanup_status, :finished)
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:container_repository_id, repository.id)
+
+ subject
+ end
+ end
+
+ context 'with repository in cleanup ongoing state' do
+ before do
+ repository.cleanup_ongoing!
+ end
+
+ it 'does not process it' do
+ expect(Projects::ContainerRepository::CleanupTagsService).not_to receive(:new)
+
+ expect { subject }.not_to change { ContainerRepository.waiting_for_cleanup.count }
+ expect(repository.cleanup_ongoing?).to be_truthy
+ end
+ end
+
+ context 'with no repository in any cleanup state' do
+ before do
+ repository.cleanup_unscheduled!
+ end
+
+ it 'does not process it' do
+ expect(Projects::ContainerRepository::CleanupTagsService).not_to receive(:new)
+
+ expect { subject }.not_to change { ContainerRepository.waiting_for_cleanup.count }
+ expect(repository.cleanup_unscheduled?).to be_truthy
+ end
+ end
+
+ context 'with no container repository waiting' do
+ before do
+ repository.destroy!
+ end
+
+ it 'does not execute the cleanup tags service' do
+ expect(Projects::ContainerRepository::CleanupTagsService).not_to receive(:new)
+
+ expect { subject }.not_to change { ContainerRepository.waiting_for_cleanup.count }
+ end
+ end
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: false)
+ end
+
+ it 'is a no-op' do
+ expect(Projects::ContainerRepository::CleanupTagsService).not_to receive(:new)
+
+ expect { subject }.not_to change { ContainerRepository.waiting_for_cleanup.count }
+ end
+ end
+
+ def cleanup_service_response(status: :finished, repository:)
+ ServiceResponse.success(message: "cleanup #{status}", payload: { cleanup_status: status, container_repository_id: repository.id })
+ end
+ end
+
+ describe '#remaining_work_count' do
+ subject { worker.remaining_work_count }
+
+ context 'with container repositoires waiting for cleanup' do
+ let_it_be(:unfinished_repositories) { create_list(:container_repository, 2, :cleanup_unfinished) }
+
+ it { is_expected.to eq(3) }
+
+ it 'logs the work count' do
+ expect_log_info(
+ cleanup_scheduled_count: 1,
+ cleanup_unfinished_count: 2,
+ cleanup_total_count: 3
+ )
+
+ subject
+ end
+ end
+
+ context 'with no container repositories waiting for cleanup' do
+ before do
+ repository.cleanup_ongoing!
+ end
+
+ it { is_expected.to eq(0) }
+
+ it 'logs 0 work count' do
+ expect_log_info(
+ cleanup_scheduled_count: 0,
+ cleanup_unfinished_count: 0,
+ cleanup_total_count: 0
+ )
+
+ subject
+ end
+ end
+ end
+
+ describe '#max_running_jobs' do
+ let(:capacity) { 50 }
+
+ subject { worker.max_running_jobs }
+
+ before do
+ stub_application_setting(container_registry_expiration_policies_worker_capacity: capacity)
+ end
+
+ it { is_expected.to eq(capacity) }
+
+ context 'with feature flag disabled' do
+ before do
+ stub_feature_flags(container_registry_expiration_policies_throttling: false)
+ end
+
+ it { is_expected.to eq(0) }
+ end
+ end
+
+ def expect_log_info(structure)
+ expect(worker.logger)
+ .to receive(:info).with(worker.structured_payload(structure))
+ end
+end
diff --git a/spec/workers/container_expiration_policy_worker_spec.rb b/spec/workers/container_expiration_policy_worker_spec.rb
index 6b185c30670..d9a4f6396f8 100644
--- a/spec/workers/container_expiration_policy_worker_spec.rb
+++ b/spec/workers/container_expiration_policy_worker_spec.rb
@@ -5,71 +5,151 @@ require 'spec_helper'
RSpec.describe ContainerExpirationPolicyWorker do
include ExclusiveLeaseHelpers
- subject { described_class.new.perform }
+ let(:worker) { described_class.new }
+ let(:started_at) { nil }
- RSpec.shared_examples 'not executing any policy' do
- it 'does not run any policy' do
- expect(ContainerExpirationPolicyService).not_to receive(:new)
+ describe '#perform' do
+ subject { worker.perform }
- subject
+ RSpec.shared_examples 'not executing any policy' do
+ it 'does not run any policy' do
+ expect(ContainerExpirationPolicyService).not_to receive(:new)
+
+ subject
+ end
end
- end
- context 'With no container expiration policies' do
- it_behaves_like 'not executing any policy'
- end
+ context 'With no container expiration policies' do
+ it 'does not execute any policies' do
+ expect(ContainerRepository).not_to receive(:for_project_id)
- context 'With container expiration policies' do
- let_it_be(:container_expiration_policy, reload: true) { create(:container_expiration_policy, :runnable) }
- let_it_be(:container_repository) { create(:container_repository, project: container_expiration_policy.project) }
- let_it_be(:user) { container_expiration_policy.project.owner }
+ expect { subject }.not_to change { ContainerRepository.cleanup_scheduled.count }
+ end
+ end
- context 'a valid policy' do
- it 'runs the policy' do
- service = instance_double(ContainerExpirationPolicyService, execute: true)
+ context 'with container expiration policies' do
+ let_it_be(:container_expiration_policy) { create(:container_expiration_policy, :runnable) }
+ let_it_be(:container_repository) { create(:container_repository, project: container_expiration_policy.project) }
- expect(ContainerExpirationPolicyService)
- .to receive(:new).with(container_expiration_policy.project, user).and_return(service)
+ context 'with a valid container expiration policy' do
+ it 'schedules the next run' do
+ expect { subject }.to change { container_expiration_policy.reload.next_run_at }
+ end
- subject
- end
- end
+ it 'marks the container repository as scheduled for cleanup' do
+ expect { subject }.to change { container_repository.reload.cleanup_scheduled? }.from(false).to(true)
+ expect(ContainerRepository.cleanup_scheduled.count).to eq(1)
+ end
- context 'a disabled policy' do
- before do
- container_expiration_policy.disable!
+ it 'calls the limited capacity worker' do
+ expect(ContainerExpirationPolicies::CleanupContainerRepositoryWorker).to receive(:perform_with_capacity)
+
+ subject
+ end
end
- it_behaves_like 'not executing any policy'
- end
+ context 'with a disabled container expiration policy' do
+ before do
+ container_expiration_policy.disable!
+ end
- context 'a policy that is not due for a run' do
- before do
- container_expiration_policy.update_column(:next_run_at, 2.minutes.from_now)
+ it 'does not run the policy' do
+ expect(ContainerRepository).not_to receive(:for_project_id)
+
+ expect { subject }.not_to change { ContainerRepository.cleanup_scheduled.count }
+ end
end
- it_behaves_like 'not executing any policy'
+ context 'with an invalid container expiration policy' do
+ let(:user) { container_expiration_policy.project.owner }
+
+ before do
+ container_expiration_policy.update_column(:name_regex, '*production')
+ end
+
+ it 'disables the policy and tracks an error' do
+ expect(ContainerRepository).not_to receive(:for_project_id)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(described_class::InvalidPolicyError), container_expiration_policy_id: container_expiration_policy.id)
+
+ expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false)
+ expect(ContainerRepository.cleanup_scheduled).to be_empty
+ end
+ end
end
- context 'a policy linked to no container repository' do
+ context 'with exclusive lease taken' do
before do
- container_expiration_policy.container_repositories.delete_all
+ stub_exclusive_lease_taken(worker.lease_key, timeout: 5.hours)
end
- it_behaves_like 'not executing any policy'
+ it 'does not execute any policy' do
+ expect(ContainerExpirationPolicies::CleanupContainerRepositoryWorker).not_to receive(:perform_with_capacity)
+ expect(worker).not_to receive(:runnable_policies)
+
+ expect { subject }.not_to change { ContainerRepository.cleanup_scheduled.count }
+ end
end
- context 'an invalid policy' do
+ context 'with throttling disabled' do
before do
- container_expiration_policy.update_column(:name_regex, '*production')
+ stub_feature_flags(container_registry_expiration_policies_throttling: false)
end
- it 'runs the policy and tracks an error' do
- expect(ContainerExpirationPolicyService)
- .to receive(:new).with(container_expiration_policy.project, user).and_call_original
- expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(ContainerExpirationPolicyService::InvalidPolicyError), container_expiration_policy_id: container_expiration_policy.id)
+ context 'with no container expiration policies' do
+ it_behaves_like 'not executing any policy'
+ end
- expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false)
+ context 'with container expiration policies' do
+ let_it_be(:container_expiration_policy, reload: true) { create(:container_expiration_policy, :runnable) }
+ let_it_be(:container_repository) { create(:container_repository, project: container_expiration_policy.project) }
+ let_it_be(:user) { container_expiration_policy.project.owner }
+
+ context 'a valid policy' do
+ it 'runs the policy' do
+ expect(ContainerExpirationPolicyService)
+ .to receive(:new).with(container_expiration_policy.project, user).and_call_original
+ expect(CleanupContainerRepositoryWorker).to receive(:perform_async).once.and_call_original
+
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'a disabled policy' do
+ before do
+ container_expiration_policy.disable!
+ end
+
+ it_behaves_like 'not executing any policy'
+ end
+
+ context 'a policy that is not due for a run' do
+ before do
+ container_expiration_policy.update_column(:next_run_at, 2.minutes.from_now)
+ end
+
+ it_behaves_like 'not executing any policy'
+ end
+
+ context 'a policy linked to no container repository' do
+ before do
+ container_expiration_policy.container_repositories.delete_all
+ end
+
+ it_behaves_like 'not executing any policy'
+ end
+
+ context 'an invalid policy' do
+ before do
+ container_expiration_policy.update_column(:name_regex, '*production')
+ end
+
+ it 'disables the policy and tracks an error' do
+ expect(ContainerExpirationPolicyService).not_to receive(:new).with(container_expiration_policy, user)
+ expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(described_class::InvalidPolicyError), container_expiration_policy_id: container_expiration_policy.id)
+
+ expect { subject }.to change { container_expiration_policy.reload.enabled }.from(true).to(false)
+ end
+ end
end
end
end
diff --git a/spec/workers/destroy_pages_deployments_worker_spec.rb b/spec/workers/destroy_pages_deployments_worker_spec.rb
new file mode 100644
index 00000000000..2c20c9004ef
--- /dev/null
+++ b/spec/workers/destroy_pages_deployments_worker_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe DestroyPagesDeploymentsWorker do
+ subject(:worker) { described_class.new }
+
+ let(:project) { create(:project) }
+ let!(:old_deployment) { create(:pages_deployment, project: project) }
+ let!(:last_deployment) { create(:pages_deployment, project: project) }
+ let!(:another_deployment) { create(:pages_deployment) }
+
+ it "doesn't fail if project is already removed" do
+ expect do
+ worker.perform(-1)
+ end.not_to raise_error
+ end
+
+ it 'can be called without last_deployment_id' do
+ expect_next_instance_of(::Pages::DestroyDeploymentsService, project, nil) do |service|
+ expect(service).to receive(:execute).and_call_original
+ end
+
+ expect do
+ worker.perform(project.id)
+ end.to change { PagesDeployment.count }.by(-2)
+ end
+
+ it 'calls destroy service' do
+ expect_next_instance_of(::Pages::DestroyDeploymentsService, project, last_deployment.id) do |service|
+ expect(service).to receive(:execute).and_call_original
+ end
+
+ expect do
+ worker.perform(project.id, last_deployment.id)
+ end.to change { PagesDeployment.count }.by(-1)
+ end
+end
diff --git a/spec/workers/git_garbage_collect_worker_spec.rb b/spec/workers/git_garbage_collect_worker_spec.rb
index fc9115a5ea1..13089549086 100644
--- a/spec/workers/git_garbage_collect_worker_spec.rb
+++ b/spec/workers/git_garbage_collect_worker_spec.rb
@@ -272,6 +272,11 @@ RSpec.describe GitGarbageCollectWorker do
expect(before_packs.count).to be >= 1
+ expect_any_instance_of(Gitlab::GitalyClient::RepositoryService)
+ .to receive(:garbage_collect)
+ .with(bitmaps_enabled, prune: false)
+ .and_call_original
+
subject.perform(project.id, 'gc', lease_key, lease_uuid)
after_packed_refs = packed_refs(project)
after_packs = packs(project)
@@ -292,6 +297,15 @@ RSpec.describe GitGarbageCollectWorker do
subject.perform(project.id, 'gc', lease_key, lease_uuid)
end
+
+ it 'prune calls garbage_collect with the option prune: true' do
+ expect_any_instance_of(Gitlab::GitalyClient::RepositoryService)
+ .to receive(:garbage_collect)
+ .with(bitmaps_enabled, prune: true)
+ .and_return(nil)
+
+ subject.perform(project.id, 'prune', lease_key, lease_uuid)
+ end
end
context 'with bitmaps enabled' do
diff --git a/spec/workers/jira_connect/sync_branch_worker_spec.rb b/spec/workers/jira_connect/sync_branch_worker_spec.rb
index 2da3ea9d256..4aa2f89de7b 100644
--- a/spec/workers/jira_connect/sync_branch_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_branch_worker_spec.rb
@@ -4,7 +4,10 @@ require 'spec_helper'
RSpec.describe JiraConnect::SyncBranchWorker do
describe '#perform' do
- let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, group: group) }
+ let_it_be(:subscription) { create(:jira_connect_subscription, installation: create(:jira_connect_installation), namespace: group) }
+
let(:project_id) { project.id }
let(:branch_name) { 'master' }
let(:commit_shas) { %w(b83d6e3 5a62481) }
@@ -13,7 +16,7 @@ RSpec.describe JiraConnect::SyncBranchWorker do
def expect_jira_sync_service_execute(args)
expect_next_instance_of(JiraConnect::SyncService) do |instance|
- expect(instance).to receive(:execute).with(args)
+ expect(instance).to receive(:execute).with(args.merge(update_sequence_id: nil))
end
end
@@ -61,5 +64,31 @@ RSpec.describe JiraConnect::SyncBranchWorker do
subject
end
end
+
+ context 'with update_sequence_id' do
+ let(:update_sequence_id) { 1 }
+ let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
+ let(:request_body) do
+ {
+ repositories: [
+ Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
+ project,
+ commits: project.commits_by(oids: commit_shas),
+ branches: [project.repository.find_branch(branch_name)],
+ update_sequence_id: update_sequence_id
+ )
+ ]
+ }.to_json
+ end
+
+ subject { described_class.new.perform(project_id, branch_name, commit_shas, update_sequence_id) }
+
+ it 'sends the reqeust with custom update_sequence_id' do
+ expect(Atlassian::JiraConnect::Client).to receive(:post)
+ .with(URI(request_url), headers: anything, body: request_body)
+
+ subject
+ end
+ end
end
end
diff --git a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
index 764201e750a..b3c0db4f260 100644
--- a/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
+++ b/spec/workers/jira_connect/sync_merge_request_worker_spec.rb
@@ -4,14 +4,18 @@ require 'spec_helper'
RSpec.describe JiraConnect::SyncMergeRequestWorker do
describe '#perform' do
- let(:merge_request) { create(:merge_request) }
+ let_it_be(:group) { create(:group) }
+ let_it_be(:project) { create(:project, :repository, group: group) }
+ let_it_be(:subscription) { create(:jira_connect_subscription, installation: create(:jira_connect_installation), namespace: group) }
+ let_it_be(:merge_request) { create(:merge_request, source_project: project) }
+
let(:merge_request_id) { merge_request.id }
subject { described_class.new.perform(merge_request_id) }
it 'calls JiraConnect::SyncService#execute' do
expect_next_instance_of(JiraConnect::SyncService) do |service|
- expect(service).to receive(:execute).with(merge_requests: [merge_request])
+ expect(service).to receive(:execute).with(merge_requests: [merge_request], update_sequence_id: nil)
end
subject
@@ -26,5 +30,30 @@ RSpec.describe JiraConnect::SyncMergeRequestWorker do
subject
end
end
+
+ context 'with update_sequence_id' do
+ let(:update_sequence_id) { 1 }
+ let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
+ let(:request_body) do
+ {
+ repositories: [
+ Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
+ project,
+ merge_requests: [merge_request],
+ update_sequence_id: update_sequence_id
+ )
+ ]
+ }.to_json
+ end
+
+ subject { described_class.new.perform(merge_request_id, update_sequence_id) }
+
+ it 'sends the request with custom update_sequence_id' do
+ expect(Atlassian::JiraConnect::Client).to receive(:post)
+ .with(URI(request_url), headers: anything, body: request_body)
+
+ subject
+ end
+ end
end
end
diff --git a/spec/workers/jira_connect/sync_project_worker_spec.rb b/spec/workers/jira_connect/sync_project_worker_spec.rb
new file mode 100644
index 00000000000..25210de828c
--- /dev/null
+++ b/spec/workers/jira_connect/sync_project_worker_spec.rb
@@ -0,0 +1,77 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe JiraConnect::SyncProjectWorker, factory_default: :keep do
+ describe '#perform' do
+ let_it_be(:project) { create_default(:project) }
+ let!(:mr_with_jira_title) { create(:merge_request, :unique_branches, title: 'TEST-123') }
+ let!(:mr_with_jira_description) { create(:merge_request, :unique_branches, description: 'TEST-323') }
+ let!(:mr_with_other_title) { create(:merge_request, :unique_branches) }
+ let!(:jira_subscription) { create(:jira_connect_subscription, namespace: project.namespace) }
+
+ let(:jira_connect_sync_service) { JiraConnect::SyncService.new(project) }
+ let(:job_args) { [project.id, update_sequence_id] }
+ let(:update_sequence_id) { 1 }
+
+ before do
+ stub_request(:post, 'https://sample.atlassian.net/rest/devinfo/0.10/bulk').to_return(status: 200, body: '', headers: {})
+
+ jira_connect_sync_service
+ allow(JiraConnect::SyncService).to receive(:new) { jira_connect_sync_service }
+ end
+
+ context 'when the project is not found' do
+ it 'does not raise an error' do
+ expect { described_class.new.perform('non_existing_record_id', update_sequence_id) }.not_to raise_error
+ end
+ end
+
+ it 'avoids N+1 database queries' do
+ control_count = ActiveRecord::QueryRecorder.new { described_class.new.perform(project.id, update_sequence_id) }.count
+
+ create(:merge_request, :unique_branches, title: 'TEST-123')
+
+ expect { described_class.new.perform(project.id, update_sequence_id) }.not_to exceed_query_limit(control_count)
+ end
+
+ it_behaves_like 'an idempotent worker' do
+ let(:request_url) { 'https://sample.atlassian.net/rest/devinfo/0.10/bulk' }
+ let(:request_body) do
+ {
+ repositories: [
+ Atlassian::JiraConnect::Serializers::RepositoryEntity.represent(
+ project,
+ merge_requests: [mr_with_jira_description, mr_with_jira_title],
+ update_sequence_id: update_sequence_id
+ )
+ ]
+ }.to_json
+ end
+
+ it 'sends the request with custom update_sequence_id' do
+ expect(Atlassian::JiraConnect::Client).to receive(:post)
+ .exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES).times
+ .with(URI(request_url), headers: anything, body: request_body)
+
+ subject
+ end
+
+ context 'when the number of merge requests to sync is higher than the limit' do
+ let!(:most_recent_merge_request) { create(:merge_request, :unique_branches, description: 'TEST-323', title: 'TEST-123') }
+
+ before do
+ stub_const("#{described_class}::MERGE_REQUEST_LIMIT", 1)
+ end
+
+ it 'syncs only the most recent merge requests within the limit' do
+ expect(jira_connect_sync_service).to receive(:execute)
+ .exactly(IdempotentWorkerHelper::WORKER_EXEC_TIMES).times
+ .with(merge_requests: [most_recent_merge_request], update_sequence_id: update_sequence_id)
+
+ subject
+ end
+ end
+ end
+ end
+end
diff --git a/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb
new file mode 100644
index 00000000000..b5eb0f69017
--- /dev/null
+++ b/spec/workers/propagate_integration_inherit_descendant_worker_spec.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PropagateIntegrationInheritDescendantWorker do
+ let_it_be(:group) { create(:group) }
+ let_it_be(:subgroup) { create(:group, parent: group) }
+ let_it_be(:group_integration) { create(:redmine_service, group: group, project: nil) }
+ let_it_be(:subgroup_integration) { create(:redmine_service, group: subgroup, project: nil, inherit_from_id: group_integration.id) }
+
+ it_behaves_like 'an idempotent worker' do
+ let(:job_args) { [group_integration.id, subgroup_integration.id, subgroup_integration.id] }
+
+ it 'calls to BulkUpdateIntegrationService' do
+ expect(BulkUpdateIntegrationService).to receive(:new)
+ .with(group_integration, match_array(subgroup_integration)).twice
+ .and_return(double(execute: nil))
+
+ subject
+ end
+ end
+
+ context 'with an invalid integration id' do
+ it 'returns without failure' do
+ expect(BulkUpdateIntegrationService).not_to receive(:new)
+
+ subject.perform(0, subgroup_integration.id, subgroup_integration.id)
+ end
+ end
+end
diff --git a/spec/workers/propagate_integration_inherit_worker_spec.rb b/spec/workers/propagate_integration_inherit_worker_spec.rb
index cbfee29a6a0..39219eaa3b5 100644
--- a/spec/workers/propagate_integration_inherit_worker_spec.rb
+++ b/spec/workers/propagate_integration_inherit_worker_spec.rb
@@ -12,7 +12,7 @@ RSpec.describe PropagateIntegrationInheritWorker do
it_behaves_like 'an idempotent worker' do
let(:job_args) { [integration.id, integration1.id, integration3.id] }
- it 'calls to BulkCreateIntegrationService' do
+ it 'calls to BulkUpdateIntegrationService' do
expect(BulkUpdateIntegrationService).to receive(:new)
.with(integration, match_array(integration1)).twice
.and_return(double(execute: nil))
diff --git a/spec/workers/purge_dependency_proxy_cache_worker_spec.rb b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
new file mode 100644
index 00000000000..9cd3b6636f5
--- /dev/null
+++ b/spec/workers/purge_dependency_proxy_cache_worker_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe PurgeDependencyProxyCacheWorker do
+ let_it_be(:user) { create(:admin) }
+ let_it_be(:blob) { create(:dependency_proxy_blob )}
+ let_it_be(:group, reload: true) { blob.group }
+ let_it_be(:group_id) { group.id }
+
+ subject { described_class.new.perform(user.id, group_id) }
+
+ before do
+ stub_config(dependency_proxy: { enabled: true })
+ group.create_dependency_proxy_setting!(enabled: true)
+ end
+
+ describe '#perform' do
+ shared_examples 'returns nil' do
+ it 'returns nil' do
+ expect { subject }.not_to change { group.dependency_proxy_blobs.size }
+ expect(subject).to be_nil
+ end
+ end
+
+ context 'an admin user' do
+ include_examples 'an idempotent worker' do
+ let(:job_args) { [user.id, group_id] }
+
+ it 'deletes the blobs and returns ok' do
+ expect(group.dependency_proxy_blobs.size).to eq(1)
+
+ subject
+
+ expect(group.dependency_proxy_blobs.size).to eq(0)
+ end
+ end
+ end
+
+ context 'a non-admin user' do
+ let(:user) { create(:user) }
+
+ it_behaves_like 'returns nil'
+ end
+
+ context 'an invalid user id' do
+ let(:user) { double('User', id: 99999 ) }
+
+ it_behaves_like 'returns nil'
+ end
+
+ context 'an invalid group' do
+ let(:group_id) { 99999 }
+
+ it_behaves_like 'returns nil'
+ end
+ end
+end
diff --git a/spec/workers/remove_expired_members_worker_spec.rb b/spec/workers/remove_expired_members_worker_spec.rb
index 8a34b41834b..5642de05731 100644
--- a/spec/workers/remove_expired_members_worker_spec.rb
+++ b/spec/workers/remove_expired_members_worker_spec.rb
@@ -31,6 +31,50 @@ RSpec.describe RemoveExpiredMembersWorker do
end
end
+ context 'project bots' do
+ let(:project) { create(:project) }
+
+ context 'expired project bot', :sidekiq_inline do
+ let_it_be(:expired_project_bot) { create(:user, :project_bot) }
+
+ before do
+ project.add_user(expired_project_bot, :maintainer, expires_at: 1.day.from_now)
+ travel_to(3.days.from_now)
+ end
+
+ it 'removes expired project bot membership' do
+ expect { worker.perform }.to change { Member.count }.by(-1)
+ expect(Member.find_by(user_id: expired_project_bot.id)).to be_nil
+ end
+
+ it 'deletes expired project bot' do
+ worker.perform
+
+ expect(User.exists?(expired_project_bot.id)).to be(false)
+ end
+ end
+
+ context 'non-expired project bot' do
+ let_it_be(:other_project_bot) { create(:user, :project_bot) }
+
+ before do
+ project.add_user(other_project_bot, :maintainer, expires_at: 10.days.from_now)
+ travel_to(3.days.from_now)
+ end
+
+ it 'does not remove expired project bot that expires in the future' do
+ expect { worker.perform }.to change { Member.count }.by(0)
+ expect(other_project_bot.reload).to be_present
+ end
+
+ it 'does not delete project bot expiring in the future' do
+ worker.perform
+
+ expect(User.exists?(other_project_bot.id)).to be(true)
+ end
+ end
+ end
+
context 'group members' do
let_it_be(:expired_group_member) { create(:group_member, expires_at: 1.day.from_now, access_level: GroupMember::DEVELOPER) }
let_it_be(:group_member_expiring_in_future) { create(:group_member, expires_at: 10.days.from_now, access_level: GroupMember::DEVELOPER) }
diff --git a/spec/workers/repository_cleanup_worker_spec.rb b/spec/workers/repository_cleanup_worker_spec.rb
index f5887d08bd2..2b700b944d2 100644
--- a/spec/workers/repository_cleanup_worker_spec.rb
+++ b/spec/workers/repository_cleanup_worker_spec.rb
@@ -40,6 +40,8 @@ RSpec.describe RepositoryCleanupWorker do
describe '#sidekiq_retries_exhausted' do
let(:job) { { 'args' => [project.id, user.id], 'error_message' => 'Error' } }
+ subject(:sidekiq_retries_exhausted) { described_class.sidekiq_retries_exhausted_block.call(job, StandardError.new) }
+
it 'does not send a failure notification for a RecordNotFound error' do
expect(NotificationService).not_to receive(:new)
@@ -51,7 +53,13 @@ RSpec.describe RepositoryCleanupWorker do
expect(service).to receive(:repository_cleanup_failure).with(project, user, 'Error')
end
- described_class.sidekiq_retries_exhausted_block.call(job, StandardError.new)
+ sidekiq_retries_exhausted
+ end
+
+ it 'cleans up the attempt' do
+ expect(Projects::CleanupService).to receive(:cleanup_after).with(project)
+
+ sidekiq_retries_exhausted
end
end
end
diff --git a/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb
new file mode 100644
index 00000000000..0dd50efba1c
--- /dev/null
+++ b/spec/workers/schedule_merge_request_cleanup_refs_worker_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ScheduleMergeRequestCleanupRefsWorker do
+ subject(:worker) { described_class.new }
+
+ describe '#perform' do
+ before do
+ allow(MergeRequest::CleanupSchedule)
+ .to receive(:scheduled_merge_request_ids)
+ .with(described_class::LIMIT)
+ .and_return([1, 2, 3, 4])
+ end
+
+ it 'does nothing if the database is read-only' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+ expect(MergeRequestCleanupRefsWorker).not_to receive(:bulk_perform_in)
+
+ worker.perform
+ end
+
+ include_examples 'an idempotent worker' do
+ it 'schedules MergeRequestCleanupRefsWorker to be performed by batch' do
+ expect(MergeRequestCleanupRefsWorker)
+ .to receive(:bulk_perform_in)
+ .with(
+ described_class::DELAY,
+ [[1], [2], [3], [4]],
+ batch_size: described_class::BATCH_SIZE
+ )
+
+ expect(worker).to receive(:log_extra_metadata_on_done).with(:merge_requests_count, 4)
+
+ worker.perform
+ end
+ end
+ end
+end