From 3cccd102ba543e02725d247893729e5c73b38295 Mon Sep 17 00:00:00 2001 From: GitLab Bot Date: Wed, 20 Apr 2022 10:00:54 +0000 Subject: Add latest changes from gitlab-org/gitlab@14-10-stable-ee --- spec/commands/sidekiq_cluster/cli_spec.rb | 12 +- .../diffs/overflow_warning_component_spec.rb | 184 +++++ spec/components/diffs/stats_component_spec.rb | 67 ++ spec/components/pajamas/alert_component_spec.rb | 104 +++ .../controllers/concerns/import_url_params_spec.rb | 18 + .../explore/projects_controller_spec.rb | 7 + spec/controllers/graphql_controller_spec.rb | 63 ++ .../groups/group_links_controller_spec.rb | 117 +-- spec/controllers/groups/runners_controller_spec.rb | 14 +- spec/controllers/groups_controller_spec.rb | 34 +- spec/controllers/help_controller_spec.rb | 6 +- .../import/bitbucket_controller_spec.rb | 150 ++-- spec/controllers/import/github_controller_spec.rb | 42 +- .../jira_connect/events_controller_spec.rb | 11 - .../jira_connect/subscriptions_controller_spec.rb | 18 +- .../oauth/jira/authorizations_controller_spec.rb | 55 -- .../jira_dvcs/authorizations_controller_spec.rb | 55 ++ .../profiles/accounts_controller_spec.rb | 2 +- spec/controllers/profiles/keys_controller_spec.rb | 20 +- .../profiles/preferences_controller_spec.rb | 24 + .../profiles/two_factor_auths_controller_spec.rb | 25 +- .../projects/artifacts_controller_spec.rb | 3 +- .../projects/branches_controller_spec.rb | 14 +- .../controllers/projects/commit_controller_spec.rb | 46 ++ .../projects/compare_controller_spec.rb | 32 +- .../projects/environments_controller_spec.rb | 28 +- .../projects/group_links_controller_spec.rb | 130 --- .../controllers/projects/issues_controller_spec.rb | 7 + spec/controllers/projects/jobs_controller_spec.rb | 2 +- spec/controllers/projects/notes_controller_spec.rb | 16 +- .../infrastructure_registry_controller_spec.rb | 12 - .../projects/pipelines/tests_controller_spec.rb | 68 +- .../projects/services_controller_spec.rb | 5 +- .../projects/static_site_editor_controller_spec.rb | 65 +- spec/controllers/projects/todos_controller_spec.rb | 2 +- .../projects/usage_quotas_controller_spec.rb | 37 +- spec/controllers/projects_controller_spec.rb | 63 +- spec/controllers/search_controller_spec.rb | 1 + spec/controllers/sessions_controller_spec.rb | 10 +- spec/controllers/uploads_controller_spec.rb | 18 + spec/db/migration_spec.rb | 32 + spec/db/schema_spec.rb | 35 +- spec/deprecation_toolkit_env.rb | 3 - spec/events/ci/pipeline_created_event_spec.rb | 27 + .../ios_specific_templates_experiment_spec.rb | 62 ++ .../new_project_sast_enabled_experiment_spec.rb | 20 - ...orials_continuous_onboarding_experiment_spec.rb | 9 + spec/factories/alert_management/metric_images.rb | 16 + spec/factories/application_settings.rb | 1 + spec/factories/ci/builds.rb | 72 +- spec/factories/ci/job_artifacts.rb | 50 ++ spec/factories/custom_emoji.rb | 3 +- spec/factories/events.rb | 5 + .../background_migration/batched_migrations.rb | 20 +- spec/factories/go_module_versions.rb | 14 +- spec/factories/groups.rb | 9 - spec/factories/integrations.rb | 2 +- spec/factories/issues.rb | 5 + spec/factories/keys.rb | 8 + spec/factories/merge_requests.rb | 3 +- spec/factories/project_statistics.rb | 1 + spec/factories/projects.rb | 13 +- spec/factories/work_items/work_item_types.rb | 5 + spec/fast_spec_helper.rb | 1 - .../admin/admin_broadcast_messages_spec.rb | 7 +- spec/features/admin/admin_dev_ops_report_spec.rb | 68 -- spec/features/admin/admin_dev_ops_reports_spec.rb | 68 ++ spec/features/admin/admin_runners_spec.rb | 292 +++---- .../admin/admin_sees_background_migrations_spec.rb | 10 +- spec/features/admin/admin_settings_spec.rb | 93 ++- .../admin/admin_users_impersonation_tokens_spec.rb | 4 +- spec/features/admin/clusters/eks_spec.rb | 4 +- spec/features/boards/boards_spec.rb | 2 +- spec/features/boards/focus_mode_spec.rb | 2 +- spec/features/boards/multi_select_spec.rb | 6 +- spec/features/clusters/create_agent_spec.rb | 4 +- spec/features/commit_spec.rb | 4 + spec/features/commits_spec.rb | 3 + .../user_searches_sentry_errors_spec.rb | 2 +- spec/features/groups/clusters/eks_spec.rb | 4 +- spec/features/groups/clusters/user_spec.rb | 3 +- spec/features/groups/group_runners_spec.rb | 168 ++++ .../groups/import_export/export_file_spec.rb | 16 - spec/features/groups/members/manage_groups_spec.rb | 149 +++- .../features/groups/members/manage_members_spec.rb | 139 ++-- spec/features/groups/members/sort_members_spec.rb | 40 +- spec/features/groups/milestone_spec.rb | 2 +- spec/features/groups/milestones_sorting_spec.rb | 14 +- spec/features/groups/settings/ci_cd_spec.rb | 81 +- spec/features/issuables/shortcuts_issuable_spec.rb | 12 +- spec/features/issues/incident_issue_spec.rb | 32 + spec/features/issues/user_creates_issue_spec.rb | 6 + spec/features/issues/user_edits_issue_spec.rb | 6 + .../user_sees_sidebar_updates_in_realtime_spec.rb | 37 + spec/features/jira_connect/subscriptions_spec.rb | 2 +- .../features/jira_oauth_provider_authorize_spec.rb | 6 +- .../user_merges_merge_request_spec.rb | 21 +- .../merge_request/user_posts_notes_spec.rb | 2 +- .../merge_request/user_sees_merge_widget_spec.rb | 3 + .../user_suggests_changes_on_diff_spec.rb | 37 + .../merge_requests/user_mass_updates_spec.rb | 2 +- .../milestones/user_deletes_milestone_spec.rb | 2 +- spec/features/oauth_login_spec.rb | 2 +- .../profiles/personal_access_tokens_spec.rb | 4 +- spec/features/profiles/user_edit_profile_spec.rb | 25 +- .../user_visits_profile_preferences_page_spec.rb | 8 - spec/features/projects/blobs/balsamiq_spec.rb | 17 - .../blobs/blob_line_permalink_updater_spec.rb | 8 +- spec/features/projects/branches_spec.rb | 10 +- spec/features/projects/cluster_agents_spec.rb | 1 - spec/features/projects/clusters/eks_spec.rb | 2 +- spec/features/projects/clusters/gcp_spec.rb | 5 +- spec/features/projects/clusters/user_spec.rb | 5 +- spec/features/projects/clusters_spec.rb | 5 +- .../projects/commits/multi_view_diff_spec.rb | 79 +- .../projects/environments/environments_spec.rb | 6 +- .../projects/import_export/import_file_spec.rb | 6 +- .../design_management/user_uploads_designs_spec.rb | 4 +- .../projects/jobs/user_browses_jobs_spec.rb | 11 - .../members/groups_with_access_list_spec.rb | 5 +- .../features/projects/members/invite_group_spec.rb | 116 ++- spec/features/projects/members/list_spec.rb | 206 ----- .../projects/members/manage_members_spec.rb | 205 +++++ spec/features/projects/members/sorting_spec.rb | 40 +- .../projects/milestones/milestones_sorting_spec.rb | 62 +- spec/features/projects/new_project_spec.rb | 11 +- spec/features/projects/pipelines/pipeline_spec.rb | 13 + spec/features/projects/pipelines/pipelines_spec.rb | 1 + .../projects/releases/user_views_releases_spec.rb | 154 ++-- spec/features/projects/terraform_spec.rb | 2 +- .../features/projects/user_creates_project_spec.rb | 28 +- spec/features/projects/user_sorts_projects_spec.rb | 28 +- spec/features/projects_spec.rb | 39 + .../projects/blobs/balsamiq_spec.rb | 18 - spec/features/runners_spec.rb | 1 + .../search/user_searches_for_projects_spec.rb | 2 + .../search/user_uses_header_search_field_spec.rb | 38 +- spec/features/static_site_editor_spec.rb | 113 --- spec/features/task_lists_spec.rb | 16 +- spec/features/users/login_spec.rb | 11 +- spec/finders/bulk_imports/entities_finder_spec.rb | 32 +- spec/finders/bulk_imports/imports_finder_spec.rb | 24 +- spec/finders/ci/jobs_finder_spec.rb | 22 +- spec/finders/concerns/finder_methods_spec.rb | 51 +- .../finder_with_cross_project_access_spec.rb | 4 +- spec/finders/keys_finder_spec.rb | 55 +- .../build_infos_for_many_packages_finder_spec.rb | 136 +++ .../finders/packages/group_packages_finder_spec.rb | 16 + spec/finders/packages/packages_finder_spec.rb | 16 + .../finders/releases/group_releases_finder_spec.rb | 15 +- spec/finders/user_recent_events_finder_spec.rb | 356 ++++---- spec/finders/users_finder_spec.rb | 50 +- .../fixtures/api/schemas/entities/member_user.json | 15 +- .../api/schemas/group_link/group_group_link.json | 15 +- .../api/schemas/group_link/group_link.json | 10 +- .../api/schemas/group_link/project_group_link.json | 14 +- spec/fixtures/api/schemas/public_api/v4/agent.json | 18 + .../fixtures/api/schemas/public_api/v4/agents.json | 4 + spec/fixtures/api/schemas/public_api/v4/issue.json | 1 + .../api/schemas/public_api/v4/issue_links.json | 9 - .../schemas/public_api/v4/project_identity.json | 22 + .../api/schemas/public_api/v4/related_issues.json | 26 + .../public_api/v4/release/release_for_guest.json | 1 + .../public_api/v4/resource_access_token.json | 31 + .../public_api/v4/resource_access_tokens.json | 4 + .../api/schemas/public_api/v4/user/admin.json | 3 +- spec/fixtures/avatars/avatar1.png | Bin 0 -> 1461 bytes spec/fixtures/avatars/avatar2.png | Bin 0 -> 1665 bytes spec/fixtures/avatars/avatar3.png | Bin 0 -> 1767 bytes spec/fixtures/avatars/avatar4.png | Bin 0 -> 1624 bytes spec/fixtures/avatars/avatar5.png | Bin 0 -> 1700 bytes .../emails/service_desk_reply_to_and_from.eml | 28 - .../markdown/markdown_golden_master_examples.yml | 28 + .../master/gl-sast-report-bandit.json | 43 + .../master/gl-sast-report-gosec.json | 68 ++ .../master/gl-sast-report-semgrep-for-bandit.json | 71 ++ .../master/gl-sast-report-semgrep-for-gosec.json | 70 ++ spec/frontend/__helpers__/matchers/index.js | 1 + .../matchers/to_validate_json_schema.js | 34 + .../matchers/to_validate_json_schema_spec.js | 65 ++ spec/frontend/__helpers__/mock_apollo_helper.js | 2 +- spec/frontend/__helpers__/mock_dom_observer.js | 4 +- spec/frontend/__helpers__/vuex_action_helper.js | 1 + spec/frontend/__helpers__/yaml_transformer.js | 11 + .../__snapshots__/expires_at_field_spec.js.snap | 2 +- .../store/actions_spec.js | 67 +- .../admin/statistics_panel/store/actions_spec.js | 37 +- .../admin/topics/components/remove_avatar_spec.js | 11 +- .../admin/users/components/actions/actions_spec.js | 42 +- .../__snapshots__/delete_user_modal_spec.js.snap | 174 +--- .../components/modals/delete_user_modal_spec.js | 104 +-- .../components/modals/user_modal_manager_spec.js | 126 --- .../components/alerts_settings_wrapper_spec.js | 3 - .../components/mocks/apollo_mock.js | 2 +- .../api/alert_management_alerts_api_spec.js | 140 ++++ spec/frontend/api_spec.js | 600 +++++++------- .../authentication/u2f/authenticate_spec.js | 15 +- spec/frontend/authentication/u2f/register_spec.js | 4 +- spec/frontend/badges/components/badge_spec.js | 6 +- spec/frontend/badges/store/actions_spec.js | 260 ++---- .../stores/modules/batch_comments/actions_spec.js | 98 +-- spec/frontend/behaviors/gl_emoji_spec.js | 6 + .../frontend/blob/balsamiq/balsamiq_viewer_spec.js | 363 -------- spec/frontend/boards/boards_util_spec.js | 30 +- .../components/board_filtered_search_spec.js | 4 +- spec/frontend/boards/components/board_form_spec.js | 2 +- .../boards/components/board_top_bar_spec.js | 88 ++ .../boards/components/boards_selector_spec.js | 14 +- .../boards/components/issuable_title_spec.js | 33 - .../components/issue_board_filtered_search_spec.js | 3 +- .../boards/components/issue_time_estimate_spec.js | 8 +- spec/frontend/boards/components/item_count_spec.js | 6 +- spec/frontend/boards/stores/actions_spec.js | 70 +- spec/frontend/captcha/apollo_captcha_link_spec.js | 94 ++- .../components/ci_variable_modal_spec.js | 2 +- .../ci_variable_list/store/actions_spec.js | 92 +-- .../components/agent_empty_state_spec.js | 20 +- .../clusters_list/components/agent_table_spec.js | 2 +- .../clusters_list/components/agent_token_spec.js | 8 +- .../clusters_list/components/agents_spec.js | 2 +- .../components/available_agents_dropwdown_spec.js | 21 + .../components/clusters_actions_spec.js | 107 ++- .../components/clusters_empty_state_spec.js | 45 +- .../components/clusters_view_all_spec.js | 93 +-- spec/frontend/clusters_list/mocks/apollo.js | 1 + spec/frontend/clusters_list/store/actions_spec.js | 101 +-- .../code_navigation/components/app_spec.js | 7 +- .../frontend/code_navigation/store/actions_spec.js | 64 +- .../code_navigation/store/mutations_spec.js | 2 + spec/frontend/code_navigation/utils/index_spec.js | 30 +- .../commit/commit_box_pipeline_mini_graph_spec.js | 57 +- .../components/commit_box_pipeline_status_spec.js | 150 ++++ spec/frontend/commit/mock_data.js | 46 ++ .../commit/pipelines/pipelines_table_spec.js | 16 +- spec/frontend/commit/pipelines/utils_spec.js | 59 ++ spec/frontend/commits_spec.js | 28 +- .../__snapshots__/project_form_group_spec.js.snap | 8 +- .../components/code_block_bubble_menu_spec.js | 142 ++++ .../components/formatting_bubble_menu_spec.js | 2 +- .../components/wrappers/image_spec.js | 66 -- .../components/wrappers/media_spec.js | 69 ++ .../content_editor/extensions/attachment_spec.js | 79 +- .../extensions/code_block_highlight_spec.js | 74 +- .../content_editor/extensions/frontmatter_spec.js | 2 +- .../services/code_block_language_loader_spec.js | 120 +++ .../content_editor/services/content_editor_spec.js | 24 +- spec/frontend/contributors/store/actions_spec.js | 26 +- .../gke_cluster/stores/actions_spec.js | 25 +- spec/frontend/crm/contact_form_spec.js | 157 ---- spec/frontend/crm/contact_form_wrapper_spec.js | 88 ++ spec/frontend/crm/contacts_root_spec.js | 77 +- spec/frontend/crm/form_spec.js | 51 +- spec/frontend/crm/mock_data.js | 25 + spec/frontend/crm/new_organization_form_spec.js | 109 --- .../frontend/crm/organization_form_wrapper_spec.js | 88 ++ spec/frontend/crm/organizations_root_spec.js | 51 +- .../__snapshots__/design_note_spec.js.snap | 28 +- .../design_notes/design_discussion_spec.js | 7 +- .../components/design_notes/design_note_spec.js | 37 +- .../frontend/design_management/pages/index_spec.js | 2 +- spec/frontend/diffs/components/commit_item_spec.js | 9 +- .../diffs/components/diff_line_note_form_spec.js | 19 +- spec/frontend/diffs/store/actions_spec.js | 439 ++++------ spec/frontend/diffs/store/utils_spec.js | 2 +- spec/frontend/editor/components/helpers.js | 12 + .../source_editor_toolbar_button_spec.js | 146 ++++ .../components/source_editor_toolbar_spec.js | 116 +++ spec/frontend/editor/schema/ci/ci_schema_spec.js | 90 ++ .../default_no_additional_properties.json | 12 + .../inherit_default_no_additional_properties.json | 8 + .../job_variables_must_not_contain_objects.json | 12 + .../negative_tests/release_assets_links_empty.json | 13 + .../release_assets_links_invalid_link_type.json | 24 + .../release_assets_links_missing.json | 11 + .../negative_tests/retry_unknown_when.json | 9 + .../json_tests/positive_tests/allow_failure.json | 19 + .../ci/json_tests/positive_tests/environment.json | 75 ++ .../positive_tests/gitlab-ci-dependencies.json | 68 ++ .../ci/json_tests/positive_tests/gitlab-ci.json | 350 ++++++++ .../ci/json_tests/positive_tests/inherit.json | 54 ++ .../json_tests/positive_tests/multiple-caches.json | 24 + .../schema/ci/json_tests/positive_tests/retry.json | 60 ++ .../positive_tests/terraform_report.json | 50 ++ .../ci/json_tests/positive_tests/variables.json | 22 + .../variables_mix_string_and_user_input.json | 10 + .../schema/ci/yaml_tests/negative_tests/cache.yml | 15 + .../ci/yaml_tests/negative_tests/include.yml | 17 + .../schema/ci/yaml_tests/positive_tests/cache.yml | 25 + .../schema/ci/yaml_tests/positive_tests/filter.yml | 18 + .../ci/yaml_tests/positive_tests/include.yml | 32 + .../schema/ci/yaml_tests/positive_tests/rules.yml | 13 + .../environments/deploy_board_component_spec.js | 16 +- spec/frontend/environments/empty_state_spec.js | 53 ++ spec/frontend/environments/emtpy_state_spec.js | 24 - .../frontend/environments/environment_item_spec.js | 53 +- .../environments/environment_table_spec.js | 5 +- spec/frontend/environments/graphql/mock_data.js | 2 + .../environments/new_environment_item_spec.js | 28 + spec/frontend/error_tracking/store/actions_spec.js | 19 +- .../error_tracking/store/details/actions_spec.js | 26 +- .../error_tracking/store/list/actions_spec.js | 16 +- .../error_tracking_settings/store/actions_spec.js | 72 +- .../feature_flags/store/edit/actions_spec.js | 55 +- .../feature_flags/store/index/actions_spec.js | 86 +- .../feature_flags/store/new/actions_spec.js | 25 +- .../droplab/plugins/ajax_filter_spec.js | 22 +- .../filtered_search_manager_spec.js | 18 +- .../services/recent_searches_service_spec.js | 58 +- .../filtered_search/visual_token_value_spec.js | 91 +- spec/frontend/fixtures/startup_css.rb | 8 +- spec/frontend/frequent_items/store/actions_spec.js | 70 +- spec/frontend/google_cloud/components/app_spec.js | 2 +- spec/frontend/gpg_badges_spec.js | 52 +- .../groups/components/item_type_icon_spec.js | 25 +- spec/frontend/header_search/components/app_spec.js | 14 +- .../header_search_autocomplete_items_spec.js | 98 ++- .../components/header_search_scoped_items_spec.js | 31 +- spec/frontend/header_search/mock_data.js | 131 ++- spec/frontend/header_spec.js | 10 - .../ide/components/commit_sidebar/form_spec.js | 2 +- spec/frontend/ide/components/ide_side_bar_spec.js | 2 +- .../ide/components/new_dropdown/upload_spec.js | 22 +- .../ide/stores/actions/merge_request_spec.js | 387 ++++----- spec/frontend/ide/stores/actions/project_spec.js | 172 ++-- spec/frontend/ide/stores/actions/tree_spec.js | 92 +-- spec/frontend/ide/stores/actions_spec.js | 537 +++++------- .../ide/stores/modules/branches/actions_spec.js | 30 +- .../ide/stores/modules/clientside/actions_spec.js | 8 +- .../ide/stores/modules/commit/actions_spec.js | 384 +++------ .../stores/modules/file_templates/actions_spec.js | 62 +- .../stores/modules/merge_requests/actions_spec.js | 35 +- .../ide/stores/modules/pane/actions_spec.js | 27 +- .../ide/stores/modules/pipelines/actions_spec.js | 152 ++-- .../stores/modules/terminal_sync/actions_spec.js | 31 +- spec/frontend/ide/stores/plugins/terminal_spec.js | 16 +- .../image_diff/init_discussion_tab_spec.js | 6 +- .../image_diff/replaced_image_diff_spec.js | 64 +- .../components/import_status_spec.js | 145 ++++ .../components/import_projects_table_spec.js | 2 +- .../components/provider_repo_table_row_spec.js | 7 + .../import_projects/store/mutations_spec.js | 29 + .../incidents/components/incidents_list_spec.js | 30 +- .../edit/components/integration_form_spec.js | 63 -- .../edit/components/jira_issues_fields_spec.js | 44 +- .../invite_members/components/group_select_spec.js | 5 +- .../components/invite_groups_modal_spec.js | 13 +- .../components/invite_members_modal_spec.js | 131 +-- .../components/invite_modal_base_spec.js | 24 +- .../components/members_token_select_spec.js | 4 +- .../components/user_limit_notification_spec.js | 71 ++ .../invite_members/mock_data/api_responses.js | 62 +- .../invite_members/mock_data/group_modal.js | 1 + .../invite_members/mock_data/member_modal.js | 1 + .../utils/response_message_parser_spec.js | 28 +- spec/frontend/issuable/issuable_form_spec.js | 44 +- .../issues/create_merge_request_dropdown_spec.js | 19 +- .../list/components/issue_card_time_info_spec.js | 13 +- .../issues/list/components/issues_list_app_spec.js | 27 +- spec/frontend/issues/list/mock_data.js | 2 +- .../related_merge_requests/store/actions_spec.js | 40 +- spec/frontend/issues/show/components/app_spec.js | 84 +- .../issues/show/components/description_spec.js | 170 ++-- .../show/components/fields/description_spec.js | 5 +- .../components/fields/description_template_spec.js | 101 ++- .../issues/show/components/fields/title_spec.js | 4 +- .../components/incidents/incident_tabs_spec.js | 18 +- spec/frontend/issues/show/mock_data/mock_data.js | 15 + .../add_namespace_modal/groups_list_item_spec.js | 2 +- .../subscriptions/components/app_spec.js | 28 + .../components/browser_support_alert_spec.js | 37 + .../components/compatibility_alert_spec.js | 2 +- .../subscriptions/pages/sign_in_spec.js | 2 +- .../components/jira_import_form_spec.js | 21 +- .../filtered_search/jobs_filtered_search_spec.js | 49 ++ .../tokens/job_status_token_spec.js | 57 ++ spec/frontend/jobs/components/job_app_spec.js | 23 +- .../components/table/graphql/cache_config_spec.js | 20 + .../jobs/components/table/job_table_app_spec.js | 105 ++- .../jobs/components/table/jobs_table_tabs_spec.js | 46 +- .../frontend/jobs/components/trigger_block_spec.js | 7 +- spec/frontend/jobs/mock_data.js | 16 +- spec/frontend/jobs/store/actions_spec.js | 138 ++-- .../labels/components/promote_label_modal_spec.js | 38 +- ...s_network_errors_during_navigation_link_spec.js | 39 +- spec/frontend/lib/gfm/index_spec.js | 46 ++ .../lib/utils/apollo_startup_js_link_spec.js | 51 +- spec/frontend/lib/utils/common_utils_spec.js | 69 +- .../confirm_via_gl_modal/confirm_modal_spec.js | 26 +- .../datetime/date_calculation_utility_spec.js | 17 + .../lib/utils/datetime/date_format_utility_spec.js | 12 + .../lib/utils/datetime/timeago_utility_spec.js | 50 +- spec/frontend/lib/utils/poll_spec.js | 60 +- spec/frontend/lib/utils/text_markdown_spec.js | 17 +- .../utils/unit_format/formatter_factory_spec.js | 50 +- spec/frontend/lib/utils/unit_format/index_spec.js | 15 + spec/frontend/lib/utils/users_cache_spec.js | 108 +-- .../members/components/table/members_table_spec.js | 19 +- spec/frontend/members/mock_data.js | 3 + .../frontend/merge_conflicts/store/actions_spec.js | 85 +- .../components/delete_milestone_modal_spec.js | 37 +- .../components/milestone_combobox_spec.js | 22 +- .../__snapshots__/dashboard_template_spec.js.snap | 8 +- .../__snapshots__/empty_state_spec.js.snap | 3 + .../__snapshots__/group_empty_state_spec.js.snap | 7 + .../components/dashboard_actions_menu_spec.js | 4 +- .../components/dashboard_url_time_spec.js | 2 +- spec/frontend/monitoring/store/actions_spec.js | 335 +++----- spec/frontend/monitoring/store/utils_spec.js | 4 +- spec/frontend/mr_notes/stores/actions_spec.js | 88 +- .../components/diff_discussion_header_spec.js | 2 +- .../frontend/notes/components/note_actions_spec.js | 3 +- spec/frontend/notes/components/note_form_spec.js | 9 +- spec/frontend/notes/components/note_header_spec.js | 28 +- .../notes/components/noteable_discussion_spec.js | 1 - .../notes/components/noteable_note_spec.js | 2 + spec/frontend/notes/components/notes_app_spec.js | 8 +- .../notes/components/sort_discussion_spec.js | 4 +- spec/frontend/notes/deprecated_notes_spec.js | 11 +- spec/frontend/notes/stores/actions_spec.js | 407 ++++----- .../explorer/components/delete_button_spec.js | 67 +- .../components/list_page/image_list_row_spec.js | 10 + .../components/list_page/registry_header_spec.js | 10 +- .../container_registry/explorer/mock_data.js | 2 + .../container_registry/explorer/pages/list_spec.js | 31 - .../dependency_proxy/app_spec.js | 70 ++ .../components/list/harbor_list_header_spec.js | 88 ++ .../components/list/harbor_list_row_spec.js | 99 +++ .../components/list/harbor_list_spec.js | 39 + .../harbor_registry/mock_data.js | 175 ++++ .../harbor_registry/pages/index_spec.js | 24 + .../harbor_registry/pages/list_spec.js | 140 ++++ .../components/details/store/actions_spec.js | 108 +-- .../__snapshots__/packages_list_app_spec.js.snap | 2 +- .../components/list/stores/actions_spec.js | 129 ++- .../components/list/packages_search_spec.js | 1 - .../pages/__snapshots__/list_spec.js.snap | 2 +- .../components/registry_settings_app_spec.js | 24 +- .../cleanup_policy_enabled_alert_spec.js.snap | 19 - .../__snapshots__/registry_breadcrumb_spec.js.snap | 10 +- .../cleanup_policy_enabled_alert_spec.js | 49 -- .../shared/components/registry_breadcrumb_spec.js | 8 +- spec/frontend/pager_spec.js | 57 +- .../account_and_limits_spec.js | 3 +- .../jobs/index/components/stop_jobs_modal_spec.js | 22 +- .../pages/dashboard/todos/index/todos_spec.js | 8 +- .../components/bulk_imports_history_app_spec.js | 10 + .../components/import_error_details_spec.js | 66 ++ .../history/components/import_history_app_spec.js | 205 +++++ .../pages/profiles/show/emoji_menu_spec.js | 16 +- .../learn_gitlab_section_card_spec.js.snap | 37 +- .../__snapshots__/learn_gitlab_spec.js.snap | 452 +++++----- .../components/learn_gitlab_section_link_spec.js | 53 +- .../components/project_feature_settings_spec.js | 30 +- .../permissions/components/settings_panel_spec.js | 63 +- .../shared/wikis/components/wiki_content_spec.js | 97 +++ .../shared/wikis/components/wiki_form_spec.js | 192 +---- spec/frontend/pdf/page_spec.js | 16 +- .../drawer/pipeline_editor_drawer_spec.js | 137 +-- .../components/editor/ci_editor_header_spec.js | 53 +- .../components/pipeline_editor_tabs_spec.js | 1 + .../pipeline_editor/pipeline_editor_home_spec.js | 99 ++- .../pipeline_wizard/components/wrapper_spec.js | 84 +- spec/frontend/pipeline_wizard/mock/yaml.js | 11 + .../pipelines/components/pipeline_tabs_spec.js | 61 ++ .../components/pipelines_filtered_search_spec.js | 2 +- .../pipelines/empty_state/ci_templates_spec.js | 85 ++ .../empty_state/pipelines_ci_templates_spec.js | 158 ++++ spec/frontend/pipelines/empty_state_spec.js | 2 +- .../pipelines/graph/action_component_spec.js | 11 +- .../graph/graph_component_wrapper_spec.js | 6 + spec/frontend/pipelines/pipeline_triggerer_spec.js | 81 +- spec/frontend/pipelines/pipeline_url_spec.js | 25 + .../pipelines/pipelines_ci_templates_spec.js | 206 ----- spec/frontend/pipelines/pipelines_spec.js | 2 +- .../pipelines/test_reports/stores/actions_spec.js | 51 +- .../profile/add_ssh_key_validation_spec.js | 2 +- .../diffs_colors_preview_spec.js.snap | 915 +++++++++++++++++++++ .../components/diffs_colors_preview_spec.js | 23 + .../preferences/components/diffs_colors_spec.js | 153 ++++ .../components/integration_view_spec.js | 33 +- .../frontend/projects/commit/store/actions_spec.js | 16 +- .../project_delete_button_spec.js.snap | 2 +- .../components/deployment_target_select_spec.js | 39 +- .../new/components/new_project_url_select_spec.js | 37 +- .../components/app_index_apollo_client_spec.js | 398 --------- .../frontend/releases/components/app_index_spec.js | 483 +++++++---- spec/frontend/releases/components/app_show_spec.js | 6 + .../releases_pagination_apollo_client_spec.js | 126 --- .../components/releases_pagination_spec.js | 180 ++-- .../components/releases_sort_apollo_client_spec.js | 103 --- .../releases/components/releases_sort_spec.js | 122 ++- .../releases/stores/modules/list/actions_spec.js | 197 ----- .../releases/stores/modules/list/helpers.js | 5 - .../releases/stores/modules/list/mutations_spec.js | 81 -- .../accessibility_report/store/actions_spec.js | 30 +- .../components/codequality_issue_body_spec.js | 1 + .../grouped_codequality_reports_app_spec.js | 4 +- .../codequality_report/store/actions_spec.js | 44 +- .../reports/components/report_section_spec.js | 31 + .../reports/components/summary_row_spec.js | 34 +- .../grouped_test_report/store/actions_spec.js | 44 +- .../__snapshots__/last_commit_spec.js.snap | 111 --- .../components/blob_content_viewer_spec.js | 19 +- .../repository/components/breadcrumbs_spec.js | 45 +- .../repository/components/last_commit_spec.js | 23 +- .../runner/admin_runners/admin_runners_app_spec.js | 111 ++- .../runner_status_popover_spec.js.snap | 3 + .../components/cells/runner_actions_cell_spec.js | 22 +- .../components/cells/runner_summary_cell_spec.js | 6 + .../registration/registration_dropdown_spec.js | 17 +- .../registration/registration_token_spec.js | 75 +- .../runner/components/runner_assigned_item_spec.js | 3 +- .../runner/components/runner_bulk_delete_spec.js | 103 +++ .../runner/components/runner_delete_button_spec.js | 64 +- .../components/runner_filtered_search_bar_spec.js | 6 +- .../frontend/runner/components/runner_jobs_spec.js | 2 +- .../frontend/runner/components/runner_list_spec.js | 65 +- .../runner/components/runner_pause_button_spec.js | 4 + .../runner/components/runner_projects_spec.js | 2 +- .../runner/components/runner_status_badge_spec.js | 25 +- .../components/runner_status_popover_spec.js | 36 + spec/frontend/runner/graphql/local_state_spec.js | 72 ++ .../runner/group_runners/group_runners_app_spec.js | 56 +- spec/frontend/runner/mock_data.js | 4 + spec/frontend/runner/runner_search_utils_spec.js | 40 +- spec/frontend/runner/utils_spec.js | 4 + spec/frontend/search/store/actions_spec.js | 20 +- spec/frontend/search_autocomplete_spec.js | 32 +- .../components/search_settings_spec.js | 41 +- .../security_configuration/components/app_spec.js | 14 +- .../components/feature_card_badge_spec.js | 40 + .../components/feature_card_spec.js | 27 + .../components/training_provider_list_spec.js | 16 +- spec/frontend/self_monitor/store/actions_spec.js | 50 +- .../__snapshots__/empty_state_spec.js.snap | 2 +- spec/frontend/serverless/store/actions_spec.js | 46 +- .../set_status_modal_wrapper_spec.js | 44 +- spec/frontend/shortcuts_spec.js | 2 +- spec/frontend/sidebar/assignees_realtime_spec.js | 23 +- .../components/incidents/escalation_status_spec.js | 34 + spec/frontend/sidebar/mock_data.js | 22 + spec/frontend/sidebar/participants_spec.js | 5 +- spec/frontend/snippets/components/edit_spec.js | 4 +- .../snippets/components/snippet_header_spec.js | 2 +- spec/frontend/task_list_spec.js | 32 +- .../terraform/components/empty_state_spec.js | 13 +- spec/frontend/terraform/components/mock_data.js | 35 + .../components/states_table_actions_spec.js | 3 + spec/frontend/tracking/tracking_spec.js | 66 ++ .../user_lists/components/edit_user_list_spec.js | 2 +- .../user_lists/components/new_user_list_spec.js | 2 +- .../user_lists/components/user_list_form_spec.js | 2 +- .../user_lists/components/user_list_spec.js | 2 +- .../user_lists/components/user_lists_spec.js | 2 +- .../user_lists/components/user_lists_table_spec.js | 2 +- .../frontend/user_lists/store/edit/actions_spec.js | 2 +- .../user_lists/store/edit/mutations_spec.js | 2 +- .../user_lists/store/index/actions_spec.js | 51 +- .../user_lists/store/index/mutations_spec.js | 2 +- spec/frontend/user_lists/store/new/actions_spec.js | 2 +- .../components/approvals/approvals_spec.js | 70 +- .../components/extensions/utils_spec.js | 2 +- .../components/mr_widget_memory_usage_spec.js | 44 +- .../components/states/mr_widget_merged_spec.js | 31 +- .../extensions/test_report/index_spec.js | 149 ++++ .../vue_mr_widget/mr_widget_options_spec.js | 121 +-- .../stores/artifacts_list/actions_spec.js | 35 +- spec/frontend/vue_mr_widget/test_extensions.js | 9 +- .../vue_shared/alert_details/alert_details_spec.js | 30 +- .../vue_shared/alert_details/service_spec.js | 44 + .../__snapshots__/awards_list_spec.js.snap | 127 +-- .../__snapshots__/identicon_spec.js.snap | 21 - .../vue_shared/components/awards_list_spec.js | 3 +- .../components/blob_viewers/simple_viewer_spec.js | 17 - .../filtered_search_bar_root_spec.js | 32 +- .../form/input_copy_toggle_visibility_spec.js | 10 +- .../vue_shared/components/help_popover_spec.js | 2 +- .../vue_shared/components/identicon_spec.js | 50 -- .../vue_shared/components/line_numbers_spec.js | 37 - .../components/local_storage_sync_spec.js | 277 ++----- .../components/markdown/apply_suggestion_spec.js | 23 +- .../vue_shared/components/markdown/field_spec.js | 17 +- .../vue_shared/components/markdown/header_spec.js | 2 +- .../__snapshots__/metric_images_table_spec.js.snap | 73 ++ .../metric_images/metric_images_tab_spec.js | 174 ++++ .../metric_images/metric_images_table_spec.js | 230 ++++++ .../components/metric_images/mock_data.js | 5 + .../components/metric_images/store/actions_spec.js | 158 ++++ .../metric_images/store/mutations_spec.js | 147 ++++ .../components/notes/placeholder_note_spec.js | 2 +- .../components/project_avatar/default_spec.js | 50 -- .../project_selector/project_list_item_spec.js | 9 +- .../registry/persisted_dropdown_selection_spec.js | 4 +- .../runner_aws_deployments_modal_spec.js.snap | 8 +- .../labels_select_vue/store/actions_spec.js | 74 +- .../labels_select_root_spec.js | 35 +- .../sidebar/labels_select_widget/mock_data.js | 28 + .../source_viewer/components/chunk_line_spec.js | 69 ++ .../source_viewer/components/chunk_spec.js | 82 ++ .../components/source_viewer/source_viewer_spec.js | 107 ++- .../components/source_viewer/utils_spec.js | 26 - .../user_avatar/user_avatar_image_new_spec.js | 32 +- .../user_avatar/user_avatar_image_old_spec.js | 39 +- .../user_avatar/user_avatar_list_spec.js | 25 + .../vue_shared/components/user_select_spec.js | 2 +- .../vue_shared/components/web_ide_link_spec.js | 5 +- .../list/components/issuable_list_root_spec.js | 7 +- .../store/modules/sast/actions_spec.js | 39 +- .../store/modules/secret_detection/actions_spec.js | 40 +- .../vuex_shared/modules/modal/actions_spec.js | 16 +- .../work_items/components/item_title_spec.js | 4 +- .../components/work_item_actions_spec.js | 103 +++ .../components/work_item_detail_modal_spec.js | 58 ++ .../work_items/components/work_item_detail_spec.js | 40 - .../work_items/components/work_item_title_spec.js | 117 +++ spec/frontend/work_items/mock_data.js | 97 ++- .../work_items/pages/create_work_item_spec.js | 73 +- .../work_items/pages/work_item_detail_spec.js | 99 +++ .../work_items/pages/work_item_root_spec.js | 91 +- spec/frontend/work_items/router_spec.js | 2 +- .../content_editor_integration_spec.js | 63 ++ spec/graphql/graphql_triggers_spec.rb | 16 + spec/graphql/mutations/ci/runner/delete_spec.rb | 9 +- .../environments/canary_ingress/update_spec.rb | 14 + .../mutations/saved_replies/destroy_spec.rb | 46 ++ spec/graphql/resolvers/blobs_resolver_spec.rb | 14 +- .../notification_email_resolver_spec.rb | 4 +- spec/graphql/resolvers/issues_resolver_spec.rb | 44 +- .../resolvers/project_jobs_resolver_spec.rb | 17 +- spec/graphql/resolvers/users_resolver_spec.rb | 4 +- spec/graphql/resolvers/work_item_resolver_spec.rb | 4 +- .../resolvers/work_items/types_resolver_spec.rb | 10 + spec/graphql/types/base_object_spec.rb | 20 + spec/graphql/types/ci/job_kind_enum_spec.rb | 11 + spec/graphql/types/ci/job_type_spec.rb | 1 + .../container_repository_details_type_spec.rb | 4 +- .../types/container_repository_type_spec.rb | 4 +- .../types/dependency_proxy/manifest_type_spec.rb | 2 +- spec/graphql/types/issue_sort_enum_spec.rb | 2 +- spec/graphql/types/range_input_type_spec.rb | 2 +- spec/graphql/types/repository/blob_type_spec.rb | 3 +- spec/graphql/types/subscription_type_spec.rb | 1 + spec/haml_lint/linter/documentation_links_spec.rb | 6 + .../admin/background_migrations_helper_spec.rb | 10 +- spec/helpers/application_settings_helper_spec.rb | 15 +- spec/helpers/boards_helper_spec.rb | 17 +- spec/helpers/broadcast_messages_helper_spec.rb | 29 - spec/helpers/button_helper_spec.rb | 2 +- spec/helpers/ci/pipeline_editor_helper_spec.rb | 8 +- spec/helpers/ci/pipelines_helper_spec.rb | 41 + spec/helpers/ci/runners_helper_spec.rb | 45 +- spec/helpers/clusters_helper_spec.rb | 4 + spec/helpers/colors_helper_spec.rb | 89 ++ spec/helpers/commits_helper_spec.rb | 10 +- spec/helpers/diff_helper_spec.rb | 57 +- spec/helpers/environment_helper_spec.rb | 2 +- spec/helpers/environments_helper_spec.rb | 2 +- spec/helpers/groups/group_members_helper_spec.rb | 62 +- spec/helpers/invite_members_helper_spec.rb | 2 + spec/helpers/issuables_helper_spec.rb | 2 +- spec/helpers/issues_helper_spec.rb | 14 +- spec/helpers/namespaces_helper_spec.rb | 11 + spec/helpers/packages_helper_spec.rb | 4 +- spec/helpers/preferences_helper_spec.rb | 61 ++ .../projects/alert_management_helper_spec.rb | 23 +- spec/helpers/projects/pipeline_helper_spec.rb | 23 + .../projects/security/configuration_helper_spec.rb | 6 + spec/helpers/projects_helper_spec.rb | 48 ++ .../routing/pseudonymization_helper_spec.rb | 6 +- spec/helpers/search_helper_spec.rb | 46 +- spec/helpers/timeboxes_helper_spec.rb | 28 - spec/helpers/wiki_helper_spec.rb | 4 + spec/initializers/mail_encoding_patch_spec.rb | 3 +- spec/initializers/omniauth_spec.rb | 46 ++ spec/lib/api/entities/application_setting_spec.rb | 31 + spec/lib/api/validations/validators/limit_spec.rb | 6 + spec/lib/backup/artifacts_spec.rb | 24 - spec/lib/backup/files_spec.rb | 26 +- spec/lib/backup/gitaly_backup_spec.rb | 38 +- spec/lib/backup/gitaly_rpc_backup_spec.rb | 154 ---- spec/lib/backup/lfs_spec.rb | 26 - spec/lib/backup/manager_spec.rb | 300 ++++++- spec/lib/backup/object_backup_spec.rb | 35 - spec/lib/backup/pages_spec.rb | 25 - spec/lib/backup/repositories_spec.rb | 153 +--- spec/lib/backup/task_spec.rb | 8 +- spec/lib/backup/uploads_spec.rb | 25 - spec/lib/banzai/filter/custom_emoji_filter_spec.rb | 9 +- spec/lib/banzai/filter/image_link_filter_spec.rb | 10 + spec/lib/banzai/filter/kroki_filter_spec.rb | 6 +- spec/lib/banzai/filter/plantuml_filter_spec.rb | 4 +- .../common/pipelines/entity_finisher_spec.rb | 2 + spec/lib/bulk_imports/groups/stage_spec.rb | 37 +- spec/lib/bulk_imports/projects/stage_spec.rb | 4 +- .../container_registry/gitlab_api_client_spec.rb | 189 ++++- spec/lib/container_registry/migration_spec.rb | 36 +- .../lib/error_tracking/sentry_client/issue_spec.rb | 54 +- spec/lib/gitlab/application_context_spec.rb | 13 +- spec/lib/gitlab/auth/o_auth/user_spec.rb | 47 +- ...backfill_draft_status_on_merge_requests_spec.rb | 16 +- .../backfill_group_features_spec.rb | 28 + ...fill_incident_issue_escalation_statuses_spec.rb | 27 - .../backfill_issue_search_data_spec.rb | 2 +- ...backfill_namespace_id_for_project_route_spec.rb | 53 ++ .../backfill_work_item_type_id_for_issues_spec.rb | 67 ++ ..._issue_work_item_type_batching_strategy_spec.rb | 135 +++ ...t_namespace_per_group_batching_strategy_spec.rb | 2 +- .../primary_key_batching_strategy_spec.rb | 29 +- .../cleanup_draft_data_from_faulty_regex_spec.rb | 54 ++ ..._policies_linked_to_no_container_images_spec.rb | 2 +- .../encrypt_static_object_token_spec.rb | 8 + .../fix_duplicate_project_name_and_path_spec.rb | 65 ++ .../merge_topics_with_same_name_spec.rb | 135 +++ ...e_shimo_confluence_integration_category_spec.rb | 28 + ...ate_container_repository_migration_plan_spec.rb | 44 + .../populate_namespace_statistics_spec.rb | 71 ++ .../populate_vulnerability_reads_spec.rb | 2 +- .../backfill_project_namespaces_spec.rb | 2 +- ...move_duplicate_vulnerabilities_findings_spec.rb | 2 +- ..._and_duplicate_vulnerabilities_findings_spec.rb | 2 +- ...ners_token_encrypted_values_on_projects_spec.rb | 2 +- ...ate_ci_runners_token_values_on_projects_spec.rb | 2 +- spec/lib/gitlab/blame_spec.rb | 79 +- spec/lib/gitlab/ci/build/image_spec.rb | 2 +- spec/lib/gitlab/ci/config/entry/image_spec.rb | 16 +- .../config/entry/reports/coverage_report_spec.rb | 2 +- spec/lib/gitlab/ci/config/entry/root_spec.rb | 14 +- .../ci/config/external/file/artifact_spec.rb | 71 +- .../gitlab/ci/config/external/file/base_spec.rb | 75 +- .../gitlab/ci/config/external/file/local_spec.rb | 42 +- .../gitlab/ci/config/external/file/project_spec.rb | 83 +- .../gitlab/ci/config/external/file/remote_spec.rb | 48 +- .../ci/config/external/file/template_spec.rb | 27 +- spec/lib/gitlab/ci/config/external/mapper_spec.rb | 77 +- .../gitlab/ci/config/external/processor_spec.rb | 64 +- spec/lib/gitlab/ci/config_spec.rb | 74 +- spec/lib/gitlab/ci/parsers/security/common_spec.rb | 236 +++--- .../security/validators/schema_validator_spec.rb | 662 ++++++++++++++- .../ci/pipeline/chain/limit/rate_limit_spec.rb | 179 ++++ .../ci/pipeline/chain/template_usage_spec.rb | 2 +- spec/lib/gitlab/ci/reports/security/report_spec.rb | 16 + .../lib/gitlab/ci/reports/security/scanner_spec.rb | 1 + spec/lib/gitlab/ci/runner_releases_spec.rb | 114 +++ spec/lib/gitlab/ci/runner_upgrade_check_spec.rb | 89 ++ spec/lib/gitlab/ci/status/build/manual_spec.rb | 18 +- spec/lib/gitlab/ci/templates/MATLAB_spec.rb | 26 + spec/lib/gitlab/ci/templates/templates_spec.rb | 3 +- .../ci/templates/themekit_gitlab_ci_yaml_spec.rb | 60 ++ spec/lib/gitlab/ci/variables/builder_spec.rb | 362 +++++--- spec/lib/gitlab/ci/yaml_processor_spec.rb | 28 +- spec/lib/gitlab/config/loader/yaml_spec.rb | 10 +- .../content_security_policy/config_loader_spec.rb | 20 +- spec/lib/gitlab/data_builder/deployment_spec.rb | 37 + spec/lib/gitlab/data_builder/note_spec.rb | 79 +- .../background_migration/batch_metrics_spec.rb | 30 +- .../background_migration/batched_job_spec.rb | 87 +- .../batched_migration_runner_spec.rb | 40 +- .../background_migration/batched_migration_spec.rb | 120 ++- .../batched_migration_wrapper_spec.rb | 110 +-- .../prometheus_metrics_spec.rb | 118 +++ .../gitlab/database/consistency_checker_spec.rb | 189 +++++ spec/lib/gitlab/database/each_database_spec.rb | 9 + .../gitlab/database/load_balancing/setup_spec.rb | 89 +- .../restrict_gitlab_schema_spec.rb | 22 +- .../gitlab/database/migration_helpers/v2_spec.rb | 6 + spec/lib/gitlab/database/migration_helpers_spec.rb | 13 +- spec/lib/gitlab/database/migration_spec.rb | 2 +- .../batched_background_migration_helpers_spec.rb | 2 +- .../database/migrations/instrumentation_spec.rb | 25 +- spec/lib/gitlab/database/migrations/runner_spec.rb | 12 + .../migrations/test_background_runner_spec.rb | 53 +- .../table_management_helpers_spec.rb | 26 + .../query_analyzers/gitlab_schemas_metrics_spec.rb | 76 +- .../database/reindexing/grafana_notifier_spec.rb | 58 +- .../schema_cache_with_renamed_table_spec.rb | 4 +- spec/lib/gitlab/database_spec.rb | 74 +- spec/lib/gitlab/diff/custom_diff_spec.rb | 53 ++ spec/lib/gitlab/diff/file_spec.rb | 98 ++- .../diff/rendered/notebook/diff_file_spec.rb | 22 + .../email/handler/service_desk_handler_spec.rb | 14 - .../message/in_product_marketing/base_spec.rb | 1 - .../in_product_marketing/invite_team_spec.rb | 39 - .../email/message/in_product_marketing_spec.rb | 1 - spec/lib/gitlab/encoding_helper_spec.rb | 10 + spec/lib/gitlab/gfm/uploads_rewriter_spec.rb | 2 +- spec/lib/gitlab/git/blame_spec.rb | 98 ++- spec/lib/gitlab/git/diff_spec.rb | 58 +- spec/lib/gitlab/git/repository_spec.rb | 2 +- .../gitlab/gitaly_client/commit_service_spec.rb | 35 + .../importer/diff_notes_importer_spec.rb | 6 +- .../github_import/importer/issues_importer_spec.rb | 6 +- .../importer/lfs_objects_importer_spec.rb | 6 +- .../github_import/importer/notes_importer_spec.rb | 6 +- .../gitlab/github_import/object_counter_spec.rb | 10 +- .../github_import/parallel_scheduling_spec.rb | 11 +- spec/lib/gitlab/gon_helper_spec.rb | 28 + spec/lib/gitlab/graphql/known_operations_spec.rb | 6 +- .../active_record_array_connection_spec.rb | 135 +++ .../keyset/connection_generic_keyset_spec.rb | 13 +- .../graphql/pagination/keyset/connection_spec.rb | 127 +-- spec/lib/gitlab/hook_data/issuable_builder_spec.rb | 8 +- .../gitlab/hook_data/merge_request_builder_spec.rb | 1 + spec/lib/gitlab/http_connection_adapter_spec.rb | 10 - spec/lib/gitlab/import_export/all_models.yml | 1 + .../gitlab/import_export/command_line_util_spec.rb | 2 +- .../import_export/duration_measuring_spec.rb | 35 + .../json/streaming_serializer_spec.rb | 2 +- .../gitlab/import_export/version_checker_spec.rb | 3 +- spec/lib/gitlab/insecure_key_fingerprint_spec.rb | 9 +- .../legacy_github_import/project_creator_spec.rb | 4 +- spec/lib/gitlab/metrics/rails_slis_spec.rb | 6 +- spec/lib/gitlab/omniauth_initializer_spec.rb | 12 + .../keyset/column_order_definition_spec.rb | 16 +- .../in_operator_optimization/query_builder_spec.rb | 39 +- spec/lib/gitlab/pagination/keyset/iterator_spec.rb | 12 +- spec/lib/gitlab/pagination/keyset/order_spec.rb | 57 +- .../pagination/keyset/simple_order_builder_spec.rb | 107 ++- .../gitlab/pagination/offset_pagination_spec.rb | 70 +- spec/lib/gitlab/patch/database_config_spec.rb | 126 +++ .../gitlab/patch/legacy_database_config_spec.rb | 126 --- spec/lib/gitlab/path_regex_spec.rb | 2 +- spec/lib/gitlab/project_template_spec.rb | 2 +- .../quick_actions/command_definition_spec.rb | 7 +- spec/lib/gitlab/search_context/builder_spec.rb | 1 - .../lib/gitlab/security/scan_configuration_spec.rb | 10 + spec/lib/gitlab/seeder_spec.rb | 27 + .../sidekiq_middleware/server_metrics_spec.rb | 2 +- .../worker_context/client_spec.rb | 2 +- .../worker_context/server_spec.rb | 2 +- spec/lib/gitlab/ssh_public_key_spec.rb | 74 +- spec/lib/gitlab/suggestions/commit_message_spec.rb | 131 ++- spec/lib/gitlab/suggestions/suggestion_set_spec.rb | 116 +-- spec/lib/gitlab/tracking_spec.rb | 38 + spec/lib/gitlab/url_sanitizer_spec.rb | 17 + spec/lib/gitlab/usage/service_ping_report_spec.rb | 137 ++- .../ci_template_unique_counter_spec.rb | 44 +- .../gitlab_cli_activity_unique_counter_spec.rb | 15 + spec/lib/gitlab/usage_data_queries_spec.rb | 12 +- spec/lib/gitlab/usage_data_spec.rb | 5 - .../utils/delegator_override/validator_spec.rb | 9 + spec/lib/gitlab/view/presenter/base_spec.rb | 34 +- .../gitlab/web_ide/config/entry/terminal_spec.rb | 4 +- spec/lib/gitlab/web_ide/config_spec.rb | 4 +- spec/lib/gitlab/workhorse_spec.rb | 8 + spec/lib/mattermost/session_spec.rb | 6 + .../cleanup_multiproc_dir_service_spec.rb | 28 +- .../groups/menus/group_information_menu_spec.rb | 14 + .../projects/menus/infrastructure_menu_spec.rb | 32 + spec/lib/sidebars/projects/panel_spec.rb | 36 +- .../app/git_user_default_ssh_config_check_spec.rb | 24 + spec/mailers/emails/in_product_marketing_spec.rb | 7 +- spec/mailers/emails/profile_spec.rb | 23 + spec/mailers/notify_spec.rb | 167 +++- ...134202_copy_adoption_snapshot_namespace_spec.rb | 1 - ...fill_incident_issue_escalation_statuses_spec.rb | 25 +- ...e_statistics_with_dependency_proxy_size_spec.rb | 64 ++ ...28_schedule_merge_topics_with_same_name_spec.rb | 36 + ...29_cleanup_draft_data_from_faulty_regex_spec.rb | 40 + ...e_container_repositories_migration_plan_spec.rb | 34 + ...remove_all_issuable_escalation_statuses_spec.rb | 20 + ...322132242_update_pages_onboarding_state_spec.rb | 53 ++ ...grate_shimo_confluence_service_category_spec.rb | 34 + ...move_leftover_ci_job_artifact_deletions_spec.rb | 92 +++ ...ining_encrypt_integration_property_jobs_spec.rb | 42 + .../migrations/add_epics_relative_position_spec.rb | 29 + spec/migrations/backfill_group_features_spec.rb | 31 + ...ackfill_namespace_id_for_project_routes_spec.rb | 29 + .../backfill_work_item_type_id_on_issues_spec.rb | 52 ++ ..._issue_when_admin_changed_primary_email_spec.rb | 40 + .../finalize_project_namespaces_backfill_spec.rb | 69 ++ ...ize_traversal_ids_background_migrations_spec.rb | 60 ++ ...spaces_for_projects_with_duplicate_name_spec.rb | 51 ++ spec/migrations/remove_wiki_notes_spec.rb | 33 + ..._item_type_backfill_next_batch_strategy_spec.rb | 55 ++ spec/models/alert_management/metric_image_spec.rb | 26 + .../analytics/cycle_analytics/aggregation_spec.rb | 77 +- spec/models/application_setting_spec.rb | 49 +- spec/models/award_emoji_spec.rb | 80 ++ spec/models/blob_spec.rb | 14 + spec/models/board_spec.rb | 4 + spec/models/bulk_import_spec.rb | 15 +- spec/models/bulk_imports/entity_spec.rb | 6 +- spec/models/bulk_imports/export_status_spec.rb | 18 +- spec/models/bulk_imports/tracker_spec.rb | 4 +- spec/models/ci/bridge_spec.rb | 26 + spec/models/ci/build_dependencies_spec.rb | 4 +- spec/models/ci/build_spec.rb | 136 +-- spec/models/ci/job_artifact_spec.rb | 13 +- spec/models/ci/namespace_mirror_spec.rb | 47 ++ spec/models/ci/pipeline_spec.rb | 73 +- spec/models/ci/processable_spec.rb | 94 +++ spec/models/ci/runner_spec.rb | 8 +- spec/models/ci/secure_file_spec.rb | 32 +- spec/models/clusters/agent_spec.rb | 17 + spec/models/commit_status_spec.rb | 7 + spec/models/concerns/approvable_base_spec.rb | 4 +- .../batch_nullify_dependent_associations_spec.rb | 49 ++ spec/models/concerns/featurable_spec.rb | 172 ++-- spec/models/concerns/issuable_spec.rb | 10 + .../concerns/sensitive_serializable_hash_spec.rb | 10 - spec/models/concerns/taskable_spec.rb | 10 +- spec/models/container_repository_spec.rb | 327 +++++--- spec/models/custom_emoji_spec.rb | 2 +- spec/models/customer_relations/contact_spec.rb | 40 +- .../customer_relations/issue_contact_spec.rb | 12 + .../models/customer_relations/organization_spec.rb | 28 + spec/models/deploy_token_spec.rb | 1 + spec/models/deployment_spec.rb | 44 + spec/models/environment_spec.rb | 261 +++++- spec/models/environment_status_spec.rb | 7 + .../project_error_tracking_setting_spec.rb | 86 +- spec/models/group_group_link_spec.rb | 48 ++ spec/models/group_spec.rb | 175 +++- spec/models/groups/feature_setting_spec.rb | 13 + spec/models/integration_spec.rb | 202 ++++- .../integrations/base_third_party_wiki_spec.rb | 42 + spec/models/integrations/emails_on_push_spec.rb | 3 +- spec/models/integrations/external_wiki_spec.rb | 2 +- spec/models/integrations/field_spec.rb | 12 +- spec/models/integrations/jira_spec.rb | 2 +- spec/models/integrations/slack_spec.rb | 4 +- spec/models/issue_spec.rb | 39 +- spec/models/key_spec.rb | 52 +- spec/models/member_spec.rb | 102 ++- spec/models/merge_request_spec.rb | 16 +- spec/models/namespace_spec.rb | 6 + spec/models/namespaces/project_namespace_spec.rb | 4 +- spec/models/note_spec.rb | 125 ++- spec/models/packages/package_file_spec.rb | 22 + spec/models/packages/package_spec.rb | 4 +- spec/models/plan_limits_spec.rb | 1 + .../environments/deployment_preloader_spec.rb | 10 +- .../group_root_ancestor_preloader_spec.rb | 63 ++ spec/models/programming_language_spec.rb | 18 + spec/models/project_feature_spec.rb | 95 ++- spec/models/project_import_state_spec.rb | 59 +- spec/models/project_setting_spec.rb | 30 + spec/models/project_spec.rb | 284 ++++++- spec/models/project_statistics_spec.rb | 4 +- .../projects/build_artifacts_size_refresh_spec.rb | 6 +- spec/models/projects/topic_spec.rb | 8 + spec/models/user_preference_spec.rb | 42 + spec/models/user_spec.rb | 179 +++- .../users/in_product_marketing_email_spec.rb | 18 +- spec/models/web_ide_terminal_spec.rb | 6 +- spec/models/wiki_page_spec.rb | 15 + .../policies/alert_management/alert_policy_spec.rb | 52 +- spec/policies/note_policy_spec.rb | 33 - spec/policies/project_member_policy_spec.rb | 6 +- spec/policies/project_policy_spec.rb | 30 + spec/presenters/ci/bridge_presenter_spec.rb | 9 +- spec/presenters/ci/build_runner_presenter_spec.rb | 60 +- spec/presenters/gitlab/blame_presenter_spec.rb | 29 +- spec/presenters/issue_presenter_spec.rb | 61 +- .../project_clusterable_presenter_spec.rb | 6 + .../security/configuration_presenter_spec.rb | 1 + .../admin/background_migrations_controller_spec.rb | 6 +- spec/requests/api/alert_management_alerts_spec.rb | 411 +++++++++ spec/requests/api/award_emoji_spec.rb | 17 + spec/requests/api/bulk_imports_spec.rb | 23 + spec/requests/api/ci/job_artifacts_spec.rb | 23 +- spec/requests/api/ci/jobs_spec.rb | 9 + .../api/ci/runner/jobs_request_post_spec.rb | 42 +- spec/requests/api/ci/secure_files_spec.rb | 153 +++- spec/requests/api/clusters/agents_spec.rb | 153 ++++ spec/requests/api/composer_packages_spec.rb | 49 +- spec/requests/api/files_spec.rb | 100 +++ spec/requests/api/graphql/ci/job_spec.rb | 1 + spec/requests/api/graphql/ci/jobs_spec.rb | 50 ++ spec/requests/api/graphql/ci/runner_spec.rb | 57 +- spec/requests/api/graphql/ci/runners_spec.rb | 2 + .../api/graphql/mutations/boards/create_spec.rb | 10 + .../api/graphql/mutations/ci/job_retry_spec.rb | 20 +- .../graphql/mutations/ci/pipeline_cancel_spec.rb | 1 + .../api/graphql/mutations/issues/update_spec.rb | 22 +- .../mutations/merge_requests/set_labels_spec.rb | 8 +- .../graphql/mutations/notes/create/note_spec.rb | 23 +- .../graphql/mutations/notes/update/note_spec.rb | 36 +- .../graphql/mutations/todos/mark_all_done_spec.rb | 31 + .../mutations/user_preferences/update_spec.rb | 22 + spec/requests/api/graphql_spec.rb | 2 +- spec/requests/api/group_export_spec.rb | 129 ++- spec/requests/api/groups_spec.rb | 34 +- spec/requests/api/integrations_spec.rb | 29 +- .../internal/container_registry/migration_spec.rb | 15 +- spec/requests/api/invitations_spec.rb | 153 +++- spec/requests/api/issue_links_spec.rb | 20 +- .../requests/api/issues/get_project_issues_spec.rb | 30 + spec/requests/api/issues/issues_spec.rb | 21 + spec/requests/api/keys_spec.rb | 66 +- spec/requests/api/lint_spec.rb | 6 +- spec/requests/api/members_spec.rb | 8 +- spec/requests/api/merge_requests_spec.rb | 6 +- spec/requests/api/notes_spec.rb | 2 +- spec/requests/api/project_attributes.yml | 5 +- spec/requests/api/project_export_spec.rb | 23 + spec/requests/api/project_import_spec.rb | 90 +- spec/requests/api/projects_spec.rb | 52 +- spec/requests/api/releases_spec.rb | 91 ++ spec/requests/api/remote_mirrors_spec.rb | 66 +- spec/requests/api/repositories_spec.rb | 2 +- spec/requests/api/resource_access_tokens_spec.rb | 99 +++ spec/requests/api/settings_spec.rb | 51 +- spec/requests/api/users_spec.rb | 60 +- spec/requests/api/v3/github_spec.rb | 2 +- .../groups/crm/contacts_controller_spec.rb | 15 +- .../groups/crm/organizations_controller_spec.rb | 15 +- .../groups/email_campaigns_controller_spec.rb | 10 +- .../import/gitlab_groups_controller_spec.rb | 14 - spec/requests/jira_authorizations_spec.rb | 2 +- spec/requests/projects/work_items_spec.rb | 38 + spec/routing/admin_routing_spec.rb | 11 +- spec/routing/project_routing_spec.rb | 7 + spec/routing/uploads_routing_spec.rb | 11 + .../database/disable_referential_integrity_spec.rb | 36 + .../avoid_feature_category_not_owned_spec.rb | 69 ++ .../rubocop/cop/qa/duplicate_testcase_link_spec.rb | 36 - spec/rubocop/cop/qa/testcase_link_format_spec.rb | 45 - spec/serializers/commit_entity_spec.rb | 9 +- spec/serializers/deployment_entity_spec.rb | 6 +- spec/serializers/environment_serializer_spec.rb | 28 +- .../group_link/group_group_link_entity_spec.rb | 50 +- .../group_link/project_group_link_entity_spec.rb | 2 +- spec/serializers/member_user_entity_spec.rb | 10 +- .../metric_images/upload_service_spec.rb | 79 ++ spec/services/audit_event_service_spec.rb | 28 +- .../bulk_imports/relation_export_service_spec.rb | 12 + .../bulk_update_integration_service_spec.rb | 12 +- spec/services/ci/after_requeue_job_service_spec.rb | 23 +- .../ci/create_pipeline_service/rate_limit_spec.rb | 91 ++ spec/services/ci/create_pipeline_service_spec.rb | 14 +- .../ci/create_web_ide_terminal_service_spec.rb | 2 +- .../destroy_all_expired_service_spec.rb | 46 +- .../ci/job_artifacts/destroy_batch_service_spec.rb | 12 +- .../update_unknown_locked_status_service_spec.rb | 145 ++++ .../atomic_processing_service_spec.rb | 6 +- spec/services/ci/register_job_service_spec.rb | 14 + spec/services/ci/retry_build_service_spec.rb | 397 --------- spec/services/ci/retry_job_service_spec.rb | 413 ++++++++++ spec/services/ci/retry_pipeline_service_spec.rb | 2 +- .../database/consistency_check_service_spec.rb | 154 ++++ .../deployments/update_environment_service_spec.rb | 31 + spec/services/emails/create_service_spec.rb | 29 + spec/services/environments/stop_service_spec.rb | 25 + spec/services/event_create_service_spec.rb | 32 +- spec/services/git/branch_push_service_spec.rb | 10 + spec/services/groups/create_service_spec.rb | 37 - spec/services/groups/transfer_service_spec.rb | 122 ++- spec/services/import/github_service_spec.rb | 4 +- .../build_service_spec.rb | 20 + .../create_service_spec.rb | 23 +- .../prepare_update_service_spec.rb | 7 +- spec/services/issues/update_service_spec.rb | 29 +- spec/services/members/create_service_spec.rb | 66 ++ spec/services/members/creator_service_spec.rb | 26 + spec/services/members/invite_service_spec.rb | 447 ++++++++-- .../services/merge_requests/update_service_spec.rb | 15 +- .../dashboard/custom_dashboard_service_spec.rb | 2 +- .../dashboard/custom_metric_embed_service_spec.rb | 2 +- .../dashboard/default_embed_service_spec.rb | 2 +- .../dashboard/dynamic_embed_service_spec.rb | 2 +- .../self_monitoring_dashboard_service_spec.rb | 2 +- .../dashboard/system_dashboard_service_spec.rb | 2 +- .../dashboard/transient_embed_service_spec.rb | 2 +- .../in_product_marketing_email_records_spec.rb | 6 +- .../in_product_marketing_emails_service_spec.rb | 2 +- .../namespaces/invite_team_email_service_spec.rb | 128 --- spec/services/notes/build_service_spec.rb | 202 +++-- spec/services/notes/create_service_spec.rb | 17 +- spec/services/notes/update_service_spec.rb | 39 - .../builder/default_spec.rb | 2 +- spec/services/notification_service_spec.rb | 75 ++ .../rubygems/metadata_extraction_service_spec.rb | 8 + .../apple_target_platform_detector_service_spec.rb | 61 ++ .../cleanup_tags_service_spec.rb | 2 +- .../third_party/delete_tags_service_spec.rb | 14 +- spec/services/projects/create_service_spec.rb | 28 +- .../projects/operations/update_service_spec.rb | 31 +- .../record_target_platforms_service_spec.rb | 66 ++ ...build_artifacts_size_statistics_service_spec.rb | 13 +- spec/services/projects/transfer_service_spec.rb | 72 +- .../quick_actions/interpret_service_spec.rb | 26 +- .../service_ping/build_payload_service_spec.rb | 4 - spec/services/task_list_toggle_service_spec.rb | 21 +- spec/services/users/destroy_service_spec.rb | 35 + .../users/saved_replies/destroy_service_spec.rb | 35 + .../users/saved_replies/update_service_spec.rb | 2 +- spec/services/web_hook_service_spec.rb | 15 + spec/spec_helper.rb | 12 +- spec/support/database_cleaner.rb | 3 + spec/support/fips.rb | 27 + spec/support/gitlab_stubs/gitlab_ci.yml | 2 +- spec/support/helpers/cycle_analytics_helpers.rb | 19 + .../features/invite_members_modal_helper.rb | 40 +- spec/support/helpers/features/runner_helpers.rb | 68 ++ spec/support/helpers/gitaly_setup.rb | 2 +- spec/support/helpers/login_helpers.rb | 2 +- spec/support/helpers/navbar_structure_helper.rb | 12 + spec/support/helpers/search_helpers.rb | 7 +- spec/support/helpers/test_env.rb | 5 +- spec/support/helpers/usage_data_helpers.rb | 1 - spec/support/matchers/graphql_matchers.rb | 10 +- spec/support/matchers/markdown_matchers.rb | 2 +- spec/support/matchers/project_namespace_matcher.rb | 2 +- .../services/deploy_token_shared_examples.rb | 4 + .../issuable_update_service_shared_examples.rb | 44 + .../container_repositories_shared_context.rb | 14 +- .../finders/users_finder_shared_contexts.rb | 4 +- .../client_stubs_shared_context.rb | 4 +- .../markdown_golden_master_shared_examples.rb | 3 + .../shared_contexts/navbar_structure_context.rb | 10 +- .../serializers/group_group_link_shared_context.rb | 6 +- ...vice_ping_metrics_definitions_shared_context.rb | 9 +- spec/support/shared_contexts/url_shared_context.rb | 4 +- .../multiple_issue_boards_shared_examples.rb | 2 +- .../githubish_import_controller_shared_examples.rb | 37 +- .../controllers/wiki_actions_shared_examples.rb | 11 +- .../features/access_tokens_shared_examples.rb | 4 +- .../features/content_editor_shared_examples.rb | 46 +- .../features/inviting_members_shared_examples.rb | 175 ++++ .../project_upload_files_shared_examples.rb | 2 +- .../features/runners_shared_examples.rb | 141 ++++ .../search/search_timeouts_shared_examples.rb | 1 + .../wiki/user_creates_wiki_page_shared_examples.rb | 6 + .../wiki/user_updates_wiki_page_shared_examples.rb | 13 - .../mutations/boards_create_shared_examples.rb | 10 - .../graphql/notes_creation_shared_examples.rb | 11 + .../gitlab_style_deprecations_shared_examples.rb | 6 +- .../helpers/wiki_helpers_shared_examples.rb | 20 + .../issuable_escalation_statuses/build_examples.rb | 20 + .../lib/gitlab/event_store_shared_examples.rb | 18 + .../issuable_activity_shared_examples.rb | 3 +- .../projects/menus/zentao_menu_shared_examples.rb | 12 +- .../shared_examples/lib/wikis_api_examples.rb | 6 +- .../models/application_setting_shared_examples.rb | 2 +- .../bulk_users_by_email_load_shared_examples.rb | 39 + .../concerns/from_set_operator_shared_examples.rb | 6 + .../slack_mattermost_notifier_shared_examples.rb | 4 +- .../models/group_shared_examples.rb | 43 + .../models/issuable_hook_data_shared_examples.rb | 61 -- .../models/issuable_link_shared_examples.rb | 13 + .../models/member_shared_examples.rb | 60 +- .../models/project_shared_examples.rb | 27 + .../shared_examples/models/wiki_shared_examples.rb | 140 +++- .../policies/wiki_policies_shared_examples.rb | 6 - .../api/composer_packages_shared_examples.rb | 4 +- .../api/container_repositories_shared_examples.rb | 90 ++ ...oup_and_project_boards_query_shared_examples.rb | 2 + .../requests/api/issuable_participants_examples.rb | 30 - .../requests/api/notes_shared_examples.rb | 79 +- .../environment_serializer_shared_examples.rb | 19 +- .../boards/boards_list_service_shared_examples.rb | 2 + ...tainer_registry_auth_service_shared_examples.rb | 94 ++- .../issuable_links/create_links_shared_examples.rb | 6 +- .../views/milestone_shared_examples.rb | 78 ++ ..._background_migration_worker_shared_examples.rb | 16 +- .../git_garbage_collect_methods_shared_examples.rb | 70 -- spec/tasks/dev_rake_spec.rb | 112 +++ spec/tasks/gitlab/backup_rake_spec.rb | 46 +- spec/tasks/gitlab/db/validate_config_rake_spec.rb | 205 +++++ spec/tasks/gitlab/db_rake_spec.rb | 513 +++++++++--- ...ct_statistics_build_artifacts_size_rake_spec.rb | 44 +- spec/tasks/gitlab/setup_rake_spec.rb | 21 +- spec/tooling/danger/product_intelligence_spec.rb | 103 ++- spec/tooling/danger/project_helper_spec.rb | 34 - spec/uploaders/ci/secure_file_uploader_spec.rb | 4 +- spec/validators/addressable_url_validator_spec.rb | 4 +- .../application_settings/_ci_cd.html.haml_spec.rb | 87 ++ .../_repository_storage.html.haml_spec.rb | 32 +- .../dashboard/milestones/index.html.haml_spec.rb | 7 + .../groups/milestones/index.html.haml_spec.rb | 7 + .../runners/_sort_dropdown.html.haml_spec.rb | 21 +- spec/views/groups/show.html.haml_spec.rb | 118 --- spec/views/profiles/keys/_form.html.haml_spec.rb | 4 +- spec/views/projects/commit/show.html.haml_spec.rb | 1 + .../projects/milestones/index.html.haml_spec.rb | 7 + .../projects/pipelines/show.html.haml_spec.rb | 1 + spec/views/shared/_global_alert.html.haml_spec.rb | 46 -- .../_milestones_sort_dropdown.html.haml_spec.rb | 27 + .../shared/groups/_dropdown.html.haml_spec.rb | 27 + spec/views/shared/projects/_list.html.haml_spec.rb | 12 + spec/workers/bulk_import_worker_spec.rb | 25 +- spec/workers/bulk_imports/entity_worker_spec.rb | 45 +- .../bulk_imports/export_request_worker_spec.rb | 18 +- spec/workers/bulk_imports/pipeline_worker_spec.rb | 65 +- .../bulk_imports/stuck_import_worker_spec.rb | 36 + .../update_locked_unknown_artifacts_worker_spec.rb | 44 + spec/workers/concerns/application_worker_spec.rb | 2 +- .../migration/enqueuer_worker_spec.rb | 192 ++++- .../migration/guard_worker_spec.rb | 72 +- .../ci_database_worker_spec.rb | 2 +- .../batched_background_migration_worker_spec.rb | 2 +- ...espace_mirrors_consistency_check_worker_spec.rb | 67 ++ ...roject_mirrors_consistency_check_worker_spec.rb | 67 ++ spec/workers/every_sidekiq_worker_spec.rb | 2 +- .../github_import/import_diff_note_worker_spec.rb | 2 +- .../github_import/import_issue_worker_spec.rb | 2 +- .../github_import/import_note_worker_spec.rb | 2 +- .../import_pull_request_worker_spec.rb | 2 +- .../update_head_pipeline_worker_spec.rb | 6 +- .../namespaces/invite_team_email_worker_spec.rb | 27 - .../namespaces/root_statistics_worker_spec.rb | 2 +- .../update_root_statistics_worker_spec.rb | 6 +- .../packages/cleanup_package_file_worker_spec.rb | 60 +- spec/workers/project_export_worker_spec.rb | 26 + spec/workers/projects/post_creation_worker_spec.rb | 2 +- .../record_target_platforms_worker_spec.rb | 87 ++ .../quality/test_data_cleanup_worker_spec.rb | 44 - 1208 files changed, 37262 insertions(+), 17553 deletions(-) create mode 100644 spec/components/diffs/overflow_warning_component_spec.rb create mode 100644 spec/components/diffs/stats_component_spec.rb create mode 100644 spec/components/pajamas/alert_component_spec.rb delete mode 100644 spec/controllers/oauth/jira/authorizations_controller_spec.rb create mode 100644 spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb create mode 100644 spec/db/migration_spec.rb create mode 100644 spec/events/ci/pipeline_created_event_spec.rb create mode 100644 spec/experiments/ios_specific_templates_experiment_spec.rb delete mode 100644 spec/experiments/new_project_sast_enabled_experiment_spec.rb create mode 100644 spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb create mode 100644 spec/factories/alert_management/metric_images.rb delete mode 100644 spec/features/admin/admin_dev_ops_report_spec.rb create mode 100644 spec/features/admin/admin_dev_ops_reports_spec.rb create mode 100644 spec/features/groups/group_runners_spec.rb delete mode 100644 spec/features/projects/blobs/balsamiq_spec.rb delete mode 100644 spec/features/projects/members/list_spec.rb create mode 100644 spec/features/projects/members/manage_members_spec.rb delete mode 100644 spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb delete mode 100644 spec/features/static_site_editor_spec.rb create mode 100644 spec/finders/packages/build_infos_for_many_packages_finder_spec.rb create mode 100644 spec/fixtures/api/schemas/public_api/v4/agent.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/agents.json delete mode 100644 spec/fixtures/api/schemas/public_api/v4/issue_links.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/project_identity.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/related_issues.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/resource_access_token.json create mode 100644 spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json create mode 100644 spec/fixtures/avatars/avatar1.png create mode 100644 spec/fixtures/avatars/avatar2.png create mode 100644 spec/fixtures/avatars/avatar3.png create mode 100644 spec/fixtures/avatars/avatar4.png create mode 100644 spec/fixtures/avatars/avatar5.png delete mode 100644 spec/fixtures/emails/service_desk_reply_to_and_from.eml create mode 100644 spec/fixtures/security_reports/master/gl-sast-report-bandit.json create mode 100644 spec/fixtures/security_reports/master/gl-sast-report-gosec.json create mode 100644 spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json create mode 100644 spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json create mode 100644 spec/frontend/__helpers__/matchers/to_validate_json_schema.js create mode 100644 spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js create mode 100644 spec/frontend/__helpers__/yaml_transformer.js delete mode 100644 spec/frontend/admin/users/components/modals/user_modal_manager_spec.js create mode 100644 spec/frontend/api/alert_management_alerts_api_spec.js delete mode 100644 spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js create mode 100644 spec/frontend/boards/components/board_top_bar_spec.js delete mode 100644 spec/frontend/boards/components/issuable_title_spec.js create mode 100644 spec/frontend/commit/components/commit_box_pipeline_status_spec.js create mode 100644 spec/frontend/commit/pipelines/utils_spec.js create mode 100644 spec/frontend/content_editor/components/code_block_bubble_menu_spec.js delete mode 100644 spec/frontend/content_editor/components/wrappers/image_spec.js create mode 100644 spec/frontend/content_editor/components/wrappers/media_spec.js create mode 100644 spec/frontend/content_editor/services/code_block_language_loader_spec.js delete mode 100644 spec/frontend/crm/contact_form_spec.js create mode 100644 spec/frontend/crm/contact_form_wrapper_spec.js delete mode 100644 spec/frontend/crm/new_organization_form_spec.js create mode 100644 spec/frontend/crm/organization_form_wrapper_spec.js create mode 100644 spec/frontend/editor/components/helpers.js create mode 100644 spec/frontend/editor/components/source_editor_toolbar_button_spec.js create mode 100644 spec/frontend/editor/components/source_editor_toolbar_spec.js create mode 100644 spec/frontend/editor/schema/ci/ci_schema_spec.js create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json create mode 100644 spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml create mode 100644 spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml create mode 100644 spec/frontend/environments/empty_state_spec.js delete mode 100644 spec/frontend/environments/emtpy_state_spec.js create mode 100644 spec/frontend/import_entities/components/import_status_spec.js create mode 100644 spec/frontend/invite_members/components/user_limit_notification_spec.js create mode 100644 spec/frontend/jira_connect/subscriptions/components/browser_support_alert_spec.js create mode 100644 spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js create mode 100644 spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js create mode 100644 spec/frontend/lib/gfm/index_spec.js create mode 100644 spec/frontend/lib/utils/datetime/date_calculation_utility_spec.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_header_spec.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/mock_data.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/pages/index_spec.js create mode 100644 spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js delete mode 100644 spec/frontend/packages_and_registries/shared/components/__snapshots__/cleanup_policy_enabled_alert_spec.js.snap delete mode 100644 spec/frontend/packages_and_registries/shared/components/cleanup_policy_enabled_alert_spec.js create mode 100644 spec/frontend/pages/import/history/components/import_error_details_spec.js create mode 100644 spec/frontend/pages/import/history/components/import_history_app_spec.js create mode 100644 spec/frontend/pages/shared/wikis/components/wiki_content_spec.js create mode 100644 spec/frontend/pipelines/components/pipeline_tabs_spec.js create mode 100644 spec/frontend/pipelines/empty_state/ci_templates_spec.js create mode 100644 spec/frontend/pipelines/empty_state/pipelines_ci_templates_spec.js delete mode 100644 spec/frontend/pipelines/pipelines_ci_templates_spec.js create mode 100644 spec/frontend/profile/preferences/components/__snapshots__/diffs_colors_preview_spec.js.snap create mode 100644 spec/frontend/profile/preferences/components/diffs_colors_preview_spec.js create mode 100644 spec/frontend/profile/preferences/components/diffs_colors_spec.js delete mode 100644 spec/frontend/releases/components/app_index_apollo_client_spec.js delete mode 100644 spec/frontend/releases/components/releases_pagination_apollo_client_spec.js delete mode 100644 spec/frontend/releases/components/releases_sort_apollo_client_spec.js delete mode 100644 spec/frontend/releases/stores/modules/list/actions_spec.js delete mode 100644 spec/frontend/releases/stores/modules/list/helpers.js delete mode 100644 spec/frontend/releases/stores/modules/list/mutations_spec.js create mode 100644 spec/frontend/runner/components/__snapshots__/runner_status_popover_spec.js.snap create mode 100644 spec/frontend/runner/components/runner_bulk_delete_spec.js create mode 100644 spec/frontend/runner/components/runner_status_popover_spec.js create mode 100644 spec/frontend/runner/graphql/local_state_spec.js create mode 100644 spec/frontend/security_configuration/components/feature_card_badge_spec.js create mode 100644 spec/frontend/terraform/components/mock_data.js create mode 100644 spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js create mode 100644 spec/frontend/vue_shared/alert_details/service_spec.js delete mode 100644 spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap delete mode 100644 spec/frontend/vue_shared/components/identicon_spec.js delete mode 100644 spec/frontend/vue_shared/components/line_numbers_spec.js create mode 100644 spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap create mode 100644 spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js create mode 100644 spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js create mode 100644 spec/frontend/vue_shared/components/metric_images/mock_data.js create mode 100644 spec/frontend/vue_shared/components/metric_images/store/actions_spec.js create mode 100644 spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js delete mode 100644 spec/frontend/vue_shared/components/project_avatar/default_spec.js create mode 100644 spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js create mode 100644 spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js delete mode 100644 spec/frontend/vue_shared/components/source_viewer/utils_spec.js create mode 100644 spec/frontend/work_items/components/work_item_actions_spec.js create mode 100644 spec/frontend/work_items/components/work_item_detail_modal_spec.js delete mode 100644 spec/frontend/work_items/components/work_item_detail_spec.js create mode 100644 spec/frontend/work_items/components/work_item_title_spec.js create mode 100644 spec/frontend/work_items/pages/work_item_detail_spec.js create mode 100644 spec/frontend_integration/content_editor/content_editor_integration_spec.js create mode 100644 spec/graphql/mutations/saved_replies/destroy_spec.rb create mode 100644 spec/graphql/types/ci/job_kind_enum_spec.rb create mode 100644 spec/helpers/colors_helper_spec.rb create mode 100644 spec/helpers/projects/pipeline_helper_spec.rb create mode 100644 spec/initializers/omniauth_spec.rb create mode 100644 spec/lib/api/entities/application_setting_spec.rb delete mode 100644 spec/lib/backup/artifacts_spec.rb delete mode 100644 spec/lib/backup/gitaly_rpc_backup_spec.rb delete mode 100644 spec/lib/backup/lfs_spec.rb delete mode 100644 spec/lib/backup/object_backup_spec.rb delete mode 100644 spec/lib/backup/pages_spec.rb delete mode 100644 spec/lib/backup/uploads_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_group_features_spec.rb delete mode 100644 spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb create mode 100644 spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb create mode 100644 spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb create mode 100644 spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb create mode 100644 spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb create mode 100644 spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb create mode 100644 spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb create mode 100644 spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb create mode 100644 spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb create mode 100644 spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb create mode 100644 spec/lib/gitlab/ci/runner_releases_spec.rb create mode 100644 spec/lib/gitlab/ci/runner_upgrade_check_spec.rb create mode 100644 spec/lib/gitlab/ci/templates/MATLAB_spec.rb create mode 100644 spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb create mode 100644 spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb create mode 100644 spec/lib/gitlab/database/consistency_checker_spec.rb delete mode 100644 spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb create mode 100644 spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb create mode 100644 spec/lib/gitlab/import_export/duration_measuring_spec.rb create mode 100644 spec/lib/gitlab/patch/database_config_spec.rb delete mode 100644 spec/lib/gitlab/patch/legacy_database_config_spec.rb create mode 100644 spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb create mode 100644 spec/migrations/20220204095121_backfill_namespace_statistics_with_dependency_proxy_size_spec.rb create mode 100644 spec/migrations/20220223124428_schedule_merge_topics_with_same_name_spec.rb create mode 100644 spec/migrations/20220315171129_cleanup_draft_data_from_faulty_regex_spec.rb create mode 100644 spec/migrations/20220316202640_populate_container_repositories_migration_plan_spec.rb create mode 100644 spec/migrations/20220321234317_remove_all_issuable_escalation_statuses_spec.rb create mode 100644 spec/migrations/20220322132242_update_pages_onboarding_state_spec.rb create mode 100644 spec/migrations/20220324032250_migrate_shimo_confluence_service_category_spec.rb create mode 100644 spec/migrations/20220329175119_remove_leftover_ci_job_artifact_deletions_spec.rb create mode 100644 spec/migrations/20220412143552_consume_remaining_encrypt_integration_property_jobs_spec.rb create mode 100644 spec/migrations/add_epics_relative_position_spec.rb create mode 100644 spec/migrations/backfill_group_features_spec.rb create mode 100644 spec/migrations/backfill_namespace_id_for_project_routes_spec.rb create mode 100644 spec/migrations/backfill_work_item_type_id_on_issues_spec.rb create mode 100644 spec/migrations/cleanup_after_fixing_issue_when_admin_changed_primary_email_spec.rb create mode 100644 spec/migrations/finalize_project_namespaces_backfill_spec.rb create mode 100644 spec/migrations/finalize_traversal_ids_background_migrations_spec.rb create mode 100644 spec/migrations/fix_and_backfill_project_namespaces_for_projects_with_duplicate_name_spec.rb create mode 100644 spec/migrations/remove_wiki_notes_spec.rb create mode 100644 spec/migrations/replace_work_item_type_backfill_next_batch_strategy_spec.rb create mode 100644 spec/models/alert_management/metric_image_spec.rb create mode 100644 spec/models/concerns/batch_nullify_dependent_associations_spec.rb create mode 100644 spec/models/groups/feature_setting_spec.rb create mode 100644 spec/models/integrations/base_third_party_wiki_spec.rb create mode 100644 spec/models/preloaders/group_root_ancestor_preloader_spec.rb create mode 100644 spec/requests/api/alert_management_alerts_spec.rb create mode 100644 spec/requests/api/clusters/agents_spec.rb create mode 100644 spec/requests/projects/work_items_spec.rb create mode 100644 spec/rubocop/cop/database/disable_referential_integrity_spec.rb create mode 100644 spec/rubocop/cop/gitlab/avoid_feature_category_not_owned_spec.rb delete mode 100644 spec/rubocop/cop/qa/duplicate_testcase_link_spec.rb delete mode 100644 spec/rubocop/cop/qa/testcase_link_format_spec.rb create mode 100644 spec/services/alert_management/metric_images/upload_service_spec.rb create mode 100644 spec/services/ci/create_pipeline_service/rate_limit_spec.rb create mode 100644 spec/services/ci/job_artifacts/update_unknown_locked_status_service_spec.rb delete mode 100644 spec/services/ci/retry_build_service_spec.rb create mode 100644 spec/services/ci/retry_job_service_spec.rb create mode 100644 spec/services/database/consistency_check_service_spec.rb create mode 100644 spec/services/incident_management/issuable_escalation_statuses/build_service_spec.rb create mode 100644 spec/services/members/creator_service_spec.rb delete mode 100644 spec/services/namespaces/invite_team_email_service_spec.rb create mode 100644 spec/services/projects/apple_target_platform_detector_service_spec.rb create mode 100644 spec/services/projects/record_target_platforms_service_spec.rb create mode 100644 spec/services/users/saved_replies/destroy_service_spec.rb create mode 100644 spec/support/fips.rb create mode 100644 spec/support/helpers/features/runner_helpers.rb create mode 100644 spec/support/shared_examples/features/inviting_members_shared_examples.rb create mode 100644 spec/support/shared_examples/features/runners_shared_examples.rb create mode 100644 spec/support/shared_examples/helpers/wiki_helpers_shared_examples.rb create mode 100644 spec/support/shared_examples/incident_management/issuable_escalation_statuses/build_examples.rb create mode 100644 spec/support/shared_examples/lib/gitlab/event_store_shared_examples.rb create mode 100644 spec/support/shared_examples/models/concerns/bulk_users_by_email_load_shared_examples.rb create mode 100644 spec/support/shared_examples/models/group_shared_examples.rb delete mode 100644 spec/support/shared_examples/models/issuable_hook_data_shared_examples.rb create mode 100644 spec/support/shared_examples/models/project_shared_examples.rb create mode 100644 spec/support/shared_examples/views/milestone_shared_examples.rb create mode 100644 spec/tasks/gitlab/db/validate_config_rake_spec.rb create mode 100644 spec/views/admin/application_settings/_ci_cd.html.haml_spec.rb create mode 100644 spec/views/dashboard/milestones/index.html.haml_spec.rb create mode 100644 spec/views/groups/milestones/index.html.haml_spec.rb delete mode 100644 spec/views/groups/show.html.haml_spec.rb create mode 100644 spec/views/projects/milestones/index.html.haml_spec.rb delete mode 100644 spec/views/shared/_global_alert.html.haml_spec.rb create mode 100644 spec/views/shared/_milestones_sort_dropdown.html.haml_spec.rb create mode 100644 spec/views/shared/groups/_dropdown.html.haml_spec.rb create mode 100644 spec/workers/bulk_imports/stuck_import_worker_spec.rb create mode 100644 spec/workers/ci/update_locked_unknown_artifacts_worker_spec.rb create mode 100644 spec/workers/database/ci_namespace_mirrors_consistency_check_worker_spec.rb create mode 100644 spec/workers/database/ci_project_mirrors_consistency_check_worker_spec.rb delete mode 100644 spec/workers/namespaces/invite_team_email_worker_spec.rb create mode 100644 spec/workers/projects/record_target_platforms_worker_spec.rb delete mode 100644 spec/workers/quality/test_data_cleanup_worker_spec.rb (limited to 'spec') diff --git a/spec/commands/sidekiq_cluster/cli_spec.rb b/spec/commands/sidekiq_cluster/cli_spec.rb index 2cb3f67b03d..bbf5f2bc4d9 100644 --- a/spec/commands/sidekiq_cluster/cli_spec.rb +++ b/spec/commands/sidekiq_cluster/cli_spec.rb @@ -41,6 +41,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo end let(:supervisor) { instance_double(Gitlab::SidekiqCluster::SidekiqProcessSupervisor) } + let(:metrics_cleanup_service) { instance_double(Prometheus::CleanupMultiprocDirService, execute: nil) } before do stub_env('RAILS_ENV', 'test') @@ -54,6 +55,8 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo allow(Gitlab::ProcessManagement).to receive(:write_pid) allow(Gitlab::SidekiqCluster::SidekiqProcessSupervisor).to receive(:instance).and_return(supervisor) allow(supervisor).to receive(:supervise) + + allow(Prometheus::CleanupMultiprocDirService).to receive(:new).and_return(metrics_cleanup_service) end after do @@ -300,6 +303,13 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo allow(Gitlab::SidekiqCluster).to receive(:start).and_return([]) end + it 'wipes the metrics directory before starting workers' do + expect(metrics_cleanup_service).to receive(:execute).ordered + expect(Gitlab::SidekiqCluster).to receive(:start).ordered.and_return([]) + + cli.run(%w(foo)) + end + context 'when there are no sidekiq_health_checks settings set' do let(:sidekiq_exporter_enabled) { true } @@ -379,7 +389,7 @@ RSpec.describe Gitlab::SidekiqCluster::CLI, stub_settings_source: true do # rubo with_them do specify do if start_metrics_server - expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, wipe_metrics_dir: true, reset_signals: trapped_signals) + expect(MetricsServer).to receive(:fork).with('sidekiq', metrics_dir: metrics_dir, reset_signals: trapped_signals) else expect(MetricsServer).not_to receive(:fork) end diff --git a/spec/components/diffs/overflow_warning_component_spec.rb b/spec/components/diffs/overflow_warning_component_spec.rb new file mode 100644 index 00000000000..ee4014ee492 --- /dev/null +++ b/spec/components/diffs/overflow_warning_component_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Diffs::OverflowWarningComponent, type: :component do + include RepoHelpers + + subject(:component) do + described_class.new( + diffs: diffs, + diff_files: diff_files, + project: project, + commit: commit, + merge_request: merge_request + ) + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + let_it_be(:commit) { project.commit(sample_commit.id) } + let_it_be(:diffs) { commit.raw_diffs } + let_it_be(:diff) { diffs.first } + let_it_be(:diff_refs) { commit.diff_refs } + let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let_it_be(:diff_files) { [diff_file] } + let_it_be(:merge_request) { create(:merge_request, source_project: project) } + + let(:expected_button_classes) do + "btn gl-alert-action btn-default gl-button btn-default-secondary" + end + + describe "rendered component" do + subject { rendered_component } + + context "on a commit page" do + before do + with_controller_class Projects::CommitController do + render_inline component + end + end + + it { is_expected.to include(component.message) } + + it "links to the diff" do + expect(component.diff_link).to eq( + ActionController::Base.helpers.link_to( + _("Plain diff"), + project_commit_path(project, commit, format: :diff), + class: expected_button_classes + ) + ) + + is_expected.to include(component.diff_link) + end + + it "links to the patch" do + expect(component.patch_link).to eq( + ActionController::Base.helpers.link_to( + _("Email patch"), + project_commit_path(project, commit, format: :patch), + class: expected_button_classes + ) + ) + + is_expected.to include(component.patch_link) + end + end + + context "on a merge request page and the merge request is persisted" do + before do + with_controller_class Projects::MergeRequests::DiffsController do + render_inline component + end + end + + it { is_expected.to include(component.message) } + + it "links to the diff" do + expect(component.diff_link).to eq( + ActionController::Base.helpers.link_to( + _("Plain diff"), + merge_request_path(merge_request, format: :diff), + class: expected_button_classes + ) + ) + + is_expected.to include(component.diff_link) + end + + it "links to the patch" do + expect(component.patch_link).to eq( + ActionController::Base.helpers.link_to( + _("Email patch"), + merge_request_path(merge_request, format: :patch), + class: expected_button_classes + ) + ) + + is_expected.to include(component.patch_link) + end + end + + context "both conditions fail" do + before do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + render_inline component + end + + it { is_expected.to include(component.message) } + it { is_expected.not_to include(expected_button_classes) } + it { is_expected.not_to include("Plain diff") } + it { is_expected.not_to include("Email patch") } + end + end + + describe "#message" do + subject { component.message } + + it { is_expected.to be_a(String) } + + it "is HTML-safe" do + expect(subject.html_safe?).to be_truthy + end + end + + describe "#diff_link" do + subject { component.diff_link } + + before do + allow(component).to receive(:link_to).and_return("foo") + render_inline component + end + + it "is a string when on a commit page" do + allow(component).to receive(:commit?).and_return(true) + + is_expected.to eq("foo") + end + + it "is a string when on a merge request page" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(true) + + is_expected.to eq("foo") + end + + it "is nil in other situations" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + + is_expected.to be_nil + end + end + + describe "#patch_link" do + subject { component.patch_link } + + before do + allow(component).to receive(:link_to).and_return("foo") + render_inline component + end + + it "is a string when on a commit page" do + allow(component).to receive(:commit?).and_return(true) + + is_expected.to eq("foo") + end + + it "is a string when on a merge request page" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(true) + + is_expected.to eq("foo") + end + + it "is nil in other situations" do + allow(component).to receive(:commit?).and_return(false) + allow(component).to receive(:merge_request?).and_return(false) + + is_expected.to be_nil + end + end +end diff --git a/spec/components/diffs/stats_component_spec.rb b/spec/components/diffs/stats_component_spec.rb new file mode 100644 index 00000000000..2e5a5f2ca26 --- /dev/null +++ b/spec/components/diffs/stats_component_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Diffs::StatsComponent, type: :component do + include RepoHelpers + + subject(:component) do + described_class.new(diff_files: diff_files) + end + + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + let_it_be(:commit) { project.commit(sample_commit.id) } + let_it_be(:diffs) { commit.raw_diffs } + let_it_be(:diff) { diffs.first } + let_it_be(:diff_refs) { commit.diff_refs } + let_it_be(:diff_file) { Gitlab::Diff::File.new(diff, diff_refs: diff_refs, repository: repository) } + let_it_be(:diff_files) { [diff_file] } + + describe "rendered component" do + subject { rendered_component } + + let(:element) { page.find(".js-diff-stats-dropdown") } + + before do + render_inline component + end + + it { is_expected.to have_selector(".js-diff-stats-dropdown") } + + it "renders the data attributes" do + expect(element["data-changed"]).to eq("1") + expect(element["data-added"]).to eq("10") + expect(element["data-deleted"]).to eq("3") + + expect(Gitlab::Json.parse(element["data-files"])).to eq([{ + "href" => "##{Digest::SHA1.hexdigest(diff_file.file_path)}", + "title" => diff_file.new_path, + "name" => diff_file.file_path, + "path" => diff_file.file_path, + "icon" => "file-modified", + "iconColor" => "", + "added" => diff_file.added_lines, + "removed" => diff_file.removed_lines + }]) + end + end + + describe "#diff_file_path_text" do + it "returns full path by default" do + expect(subject.diff_file_path_text(diff_file)).to eq(diff_file.new_path) + end + + it "returns truncated path" do + expect(subject.diff_file_path_text(diff_file, max: 10)).to eq("...open.rb") + end + + it "returns the path if max is oddly small" do + expect(subject.diff_file_path_text(diff_file, max: 3)).to eq(diff_file.new_path) + end + + it "returns the path if max is oddly large" do + expect(subject.diff_file_path_text(diff_file, max: 100)).to eq(diff_file.new_path) + end + end +end diff --git a/spec/components/pajamas/alert_component_spec.rb b/spec/components/pajamas/alert_component_spec.rb new file mode 100644 index 00000000000..628d715ff64 --- /dev/null +++ b/spec/components/pajamas/alert_component_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true +require "spec_helper" + +RSpec.describe Pajamas::AlertComponent, :aggregate_failures, type: :component do + context 'with content' do + before do + render_inline(described_class.new) { '_content_' } + end + + it 'has content' do + expect(rendered_component).to have_text('_content_') + end + end + + context 'with defaults' do + before do + render_inline described_class.new + end + + it 'does not set a title' do + expect(rendered_component).not_to have_selector('.gl-alert-title') + expect(rendered_component).to have_selector('.gl-alert-icon-no-title') + end + + it 'renders the default variant' do + expect(rendered_component).to have_selector('.gl-alert-info') + expect(rendered_component).to have_selector("[data-testid='information-o-icon']") + end + + it 'renders a dismiss button' do + expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close') + expect(rendered_component).to have_selector("[data-testid='close-icon']") + end + end + + context 'with custom options' do + context 'with simple options' do + context 'without dismissible content' do + before do + render_inline described_class.new( + title: '_title_', + dismissible: false, + alert_class: '_alert_class_', + alert_data: { + feature_id: '_feature_id_', + dismiss_endpoint: '_dismiss_endpoint_' + } + ) + end + + it 'sets the title' do + expect(rendered_component).to have_selector('.gl-alert-title') + expect(rendered_component).to have_content('_title_') + expect(rendered_component).not_to have_selector('.gl-alert-icon-no-title') + end + + it 'sets to not be dismissible' do + expect(rendered_component).not_to have_selector('.gl-dismiss-btn.js-close') + expect(rendered_component).not_to have_selector("[data-testid='close-icon']") + end + + it 'sets the alert_class' do + expect(rendered_component).to have_selector('._alert_class_') + end + + it 'sets the alert_data' do + expect(rendered_component).to have_selector('[data-feature-id="_feature_id_"][data-dismiss-endpoint="_dismiss_endpoint_"]') + end + end + end + + context 'with dismissible content' do + before do + render_inline described_class.new( + close_button_class: '_close_button_class_', + close_button_data: { + testid: '_close_button_testid_' + } + ) + end + + it 'renders a dismiss button and data' do + expect(rendered_component).to have_selector('.gl-dismiss-btn.js-close._close_button_class_') + expect(rendered_component).to have_selector("[data-testid='close-icon']") + expect(rendered_component).to have_selector('[data-testid="_close_button_testid_"]') + end + end + + context 'with setting variant type' do + where(:variant) { [:warning, :success, :danger, :tip] } + + before do + render_inline described_class.new(variant: variant) + end + + with_them do + it 'renders the variant' do + expect(rendered_component).to have_selector(".gl-alert-#{variant}") + expect(rendered_component).to have_selector("[data-testid='#{described_class::ICONS[variant]}-icon']") + end + end + end + end +end diff --git a/spec/controllers/concerns/import_url_params_spec.rb b/spec/controllers/concerns/import_url_params_spec.rb index ddffb243f7a..170263d10a4 100644 --- a/spec/controllers/concerns/import_url_params_spec.rb +++ b/spec/controllers/concerns/import_url_params_spec.rb @@ -55,4 +55,22 @@ RSpec.describe ImportUrlParams do end end end + + context 'url with provided mixed credentials' do + let(:params) do + ActionController::Parameters.new(project: { + import_url: 'https://user@url.com', + import_url_user: '', import_url_password: 'password' + }) + end + + describe '#import_url_params' do + it 'returns import_url built from both url and hash credentials' do + expect(import_url_params).to eq( + import_url: 'https://user:password@url.com', + import_type: 'git' + ) + end + end + end end diff --git a/spec/controllers/explore/projects_controller_spec.rb b/spec/controllers/explore/projects_controller_spec.rb index c3f6c653376..bf578489916 100644 --- a/spec/controllers/explore/projects_controller_spec.rb +++ b/spec/controllers/explore/projects_controller_spec.rb @@ -112,6 +112,13 @@ RSpec.describe Explore::ProjectsController do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template('topic') end + + it 'finds topic by case insensitive name' do + get :topic, params: { topic_name: 'TOPIC1' } + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template('topic') + end end end end diff --git a/spec/controllers/graphql_controller_spec.rb b/spec/controllers/graphql_controller_spec.rb index dbaed8aaa19..4de31e2e135 100644 --- a/spec/controllers/graphql_controller_spec.rb +++ b/spec/controllers/graphql_controller_spec.rb @@ -134,6 +134,47 @@ RSpec.describe GraphqlController do post :execute end + + it 'calls the track gitlab cli when trackable method' do + agent = 'GLab - GitLab CLI' + request.env['HTTP_USER_AGENT'] = agent + + expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter) + .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user) + + post :execute + end + + it "assigns username in ApplicationContext" do + post :execute + + expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username) + end + end + + context 'when 2FA is required for the user' do + let(:user) { create(:user, last_activity_on: Date.yesterday) } + + before do + group = create(:group, require_two_factor_authentication: true) + group.add_developer(user) + + sign_in(user) + end + + it 'does not redirect if 2FA is enabled' do + expect(controller).not_to receive(:redirect_to) + + post :execute + + expect(response).to have_gitlab_http_status(:unauthorized) + + expected_message = "Authentication error: " \ + "enable 2FA in your profile settings to continue using GitLab: %{mfa_help_page}" % + { mfa_help_page: EnforcesTwoFactorAuthentication::MFA_HELP_PAGE } + + expect(json_response).to eq({ 'errors' => [{ 'message' => expected_message }] }) + end end context 'when user uses an API token' do @@ -189,6 +230,12 @@ RSpec.describe GraphqlController do expect(assigns(:context)[:is_sessionless_user]).to be true end + it "assigns username in ApplicationContext" do + subject + + expect(Gitlab::ApplicationContext.current).to include('meta.user' => user.username) + end + it 'calls the track api when trackable method' do agent = 'vs-code-gitlab-workflow/3.11.1 VSCode/1.52.1 Node.js/12.14.1 (darwin; x64)' request.env['HTTP_USER_AGENT'] = agent @@ -208,6 +255,16 @@ RSpec.describe GraphqlController do subject end + + it 'calls the track gitlab cli when trackable method' do + agent = 'GLab - GitLab CLI' + request.env['HTTP_USER_AGENT'] = agent + + expect(Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter) + .to receive(:track_api_request_when_trackable).with(user_agent: agent, user: user) + + subject + end end context 'when user is not logged in' do @@ -222,6 +279,12 @@ RSpec.describe GraphqlController do expect(assigns(:context)[:is_sessionless_user]).to be false end + + it "does not assign a username in ApplicationContext" do + subject + + expect(Gitlab::ApplicationContext.current.key?('meta.user')).to be false + end end it 'includes request object in context' do diff --git a/spec/controllers/groups/group_links_controller_spec.rb b/spec/controllers/groups/group_links_controller_spec.rb index fafe9715946..28febd786de 100644 --- a/spec/controllers/groups/group_links_controller_spec.rb +++ b/spec/controllers/groups/group_links_controller_spec.rb @@ -35,120 +35,6 @@ RSpec.describe Groups::GroupLinksController do end end - describe '#create' do - let(:shared_with_group_id) { shared_with_group.id } - let(:shared_group_access) { GroupGroupLink.default_access } - - subject do - post(:create, - params: { group_id: shared_group, - shared_with_group_id: shared_with_group_id, - shared_group_access: shared_group_access }) - end - - shared_examples 'creates group group link' do - it 'links group with selected group' do - expect { subject }.to change { shared_with_group.shared_groups.include?(shared_group) }.from(false).to(true) - end - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - end - - it 'allows access for group member' do - expect { subject }.to( - change { group_member.can?(:read_group, shared_group) }.from(false).to(true)) - end - end - - context 'when user has correct access to both groups' do - before do - shared_with_group.add_developer(user) - shared_group.add_owner(user) - end - - context 'when default access level is requested' do - include_examples 'creates group group link' - end - - context 'when owner access is requested' do - let(:shared_group_access) { Gitlab::Access::OWNER } - - before do - shared_with_group.add_owner(group_member) - end - - include_examples 'creates group group link' - - it 'allows admin access for group member' do - expect { subject }.to( - change { group_member.can?(:admin_group, shared_group) }.from(false).to(true)) - end - end - - it 'updates project permissions', :sidekiq_inline do - expect { subject }.to change { group_member.can?(:read_project, project) }.from(false).to(true) - end - - context 'when shared with group id is not present' do - let(:shared_with_group_id) { nil } - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - expect(flash[:alert]).to eq('Please select a group.') - end - end - - context 'when link is not persisted in the database' do - before do - allow(::Groups::GroupLinks::CreateService).to( - receive_message_chain(:new, :execute) - .and_return({ status: :error, - http_status: 409, - message: 'error' })) - end - - it 'redirects to group links page' do - subject - - expect(response).to(redirect_to(group_group_members_path(shared_group))) - expect(flash[:alert]).to eq('error') - end - end - end - - context 'when user does not have access to the group' do - before do - shared_group.add_owner(user) - end - - it 'renders 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user does not have admin access to the shared group' do - before do - shared_with_group.add_developer(user) - shared_group.add_developer(user) - end - - it 'renders 404' do - subject - - expect(response).to have_gitlab_http_status(:not_found) - end - end - - include_examples 'placeholder is passed as `id` parameter', :create - end - describe '#update' do let!(:link) do create(:group_group_link, { shared_group: shared_group, @@ -193,7 +79,8 @@ RSpec.describe Groups::GroupLinksController do subject - expect(json_response).to eq({ "expires_in" => "about 1 month", "expires_soon" => false }) + expect(json_response).to eq({ "expires_in" => controller.helpers.time_ago_with_tooltip(expiry_date), + "expires_soon" => false }) end end diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb index b4950b93a3f..a53f09e2afc 100644 --- a/spec/controllers/groups/runners_controller_spec.rb +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Groups::RunnersController do sign_in(user) end - describe '#index' do + describe '#index', :snowplow do context 'when user is owner' do before do group.add_owner(user) @@ -30,6 +30,12 @@ RSpec.describe Groups::RunnersController do expect(response).to render_template(:index) expect(assigns(:group_runners_limited_count)).to be(2) end + + it 'tracks the event' do + get :index, params: { group_id: group } + + expect_snowplow_event(category: described_class.name, action: 'index', user: user, namespace: group) + end end context 'when user is not owner' do @@ -42,6 +48,12 @@ RSpec.describe Groups::RunnersController do expect(response).to have_gitlab_http_status(:not_found) end + + it 'does not track the event' do + get :index, params: { group_id: group } + + expect_no_snowplow_event + end end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index a82c5681911..be30011905c 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -509,6 +509,14 @@ RSpec.describe GroupsController, factory_default: :keep do expect(assigns(:issues)).to eq([issue_1]) end end + + it 'saves the sort order to user preferences' do + stub_feature_flags(vue_issues_list: true) + + get :issues, params: { id: group.to_param, sort: 'priority' } + + expect(user.reload.user_preference.issues_sort).to eq('priority') + end end describe 'GET #merge_requests', :sidekiq_might_not_need_inline do @@ -1076,19 +1084,6 @@ RSpec.describe GroupsController, factory_default: :keep do enable_admin_mode!(admin) end - context 'when the group export feature flag is not enabled' do - before do - sign_in(admin) - stub_feature_flags(group_import_export: false) - end - - it 'returns a not found error' do - post :export, params: { id: group.to_param } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when the user does not have permission to export the group' do before do sign_in(guest) @@ -1189,19 +1184,6 @@ RSpec.describe GroupsController, factory_default: :keep do end end - context 'when the group export feature flag is not enabled' do - before do - sign_in(admin) - stub_feature_flags(group_import_export: false) - end - - it 'returns a not found error' do - post :export, params: { id: group.to_param } - - expect(response).to have_gitlab_http_status(:not_found) - end - end - context 'when the user does not have the required permissions' do before do sign_in(guest) diff --git a/spec/controllers/help_controller_spec.rb b/spec/controllers/help_controller_spec.rb index 4e2123c8cc4..70dc710f604 100644 --- a/spec/controllers/help_controller_spec.rb +++ b/spec/controllers/help_controller_spec.rb @@ -142,11 +142,11 @@ RSpec.describe HelpController do context 'for Markdown formats' do subject { get :show, params: { path: path }, format: :md } - let(:path) { 'ssh/index' } + let(:path) { 'user/ssh' } context 'when requested file exists' do before do - expect_file_read(File.join(Rails.root, 'doc/ssh/index.md'), content: fixture_file('blockquote_fence_after.md')) + expect_file_read(File.join(Rails.root, 'doc/user/ssh.md'), content: fixture_file('blockquote_fence_after.md')) subject end @@ -257,7 +257,7 @@ RSpec.describe HelpController do it 'always renders not found' do get :show, params: { - path: 'ssh/index' + path: 'user/ssh' }, format: :foo expect(response).to be_not_found diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 91e43adc472..6d24830af27 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -26,31 +26,55 @@ RSpec.describe Import::BitbucketController do session[:oauth_request_token] = {} end - it "updates access token" do - expires_at = Time.current + 1.day - expires_in = 1.day - access_token = double(token: token, - secret: secret, - expires_at: expires_at, - expires_in: expires_in, - refresh_token: refresh_token) - allow_any_instance_of(OAuth2::Client) - .to receive(:get_token) - .with(hash_including( - 'grant_type' => 'authorization_code', - 'code' => code, - redirect_uri: users_import_bitbucket_callback_url), - {}) - .and_return(access_token) - stub_omniauth_provider('bitbucket') - - get :callback, params: { code: code } - - expect(session[:bitbucket_token]).to eq(token) - expect(session[:bitbucket_refresh_token]).to eq(refresh_token) - expect(session[:bitbucket_expires_at]).to eq(expires_at) - expect(session[:bitbucket_expires_in]).to eq(expires_in) - expect(controller).to redirect_to(status_import_bitbucket_url) + context "when auth state param is invalid" do + let(:random_key) { "pure_random" } + let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" } + + it "redirects to external auth url" do + allow(SecureRandom).to receive(:base64).and_return(random_key) + allow_next_instance_of(OAuth2::Client) do |client| + allow(client).to receive_message_chain(:auth_code, :authorize_url) + .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key) + .and_return(external_bitbucket_auth_url) + end + + get :callback, params: { code: code, state: "invalid-token" } + + expect(controller).to redirect_to(external_bitbucket_auth_url) + end + end + + context "when auth state param is valid" do + before do + session[:bitbucket_auth_state] = 'state' + end + + it "updates access token" do + expires_at = Time.current + 1.day + expires_in = 1.day + access_token = double(token: token, + secret: secret, + expires_at: expires_at, + expires_in: expires_in, + refresh_token: refresh_token) + allow_any_instance_of(OAuth2::Client) + .to receive(:get_token) + .with(hash_including( + 'grant_type' => 'authorization_code', + 'code' => code, + redirect_uri: users_import_bitbucket_callback_url), + {}) + .and_return(access_token) + stub_omniauth_provider('bitbucket') + + get :callback, params: { code: code, state: 'state' } + + expect(session[:bitbucket_token]).to eq(token) + expect(session[:bitbucket_refresh_token]).to eq(refresh_token) + expect(session[:bitbucket_expires_at]).to eq(expires_at) + expect(session[:bitbucket_expires_in]).to eq(expires_in) + expect(controller).to redirect_to(status_import_bitbucket_url) + end end end @@ -59,46 +83,68 @@ RSpec.describe Import::BitbucketController do @repo = double(name: 'vim', slug: 'vim', owner: 'asd', full_name: 'asd/vim', clone_url: 'http://test.host/demo/url.git', 'valid?' => true) @invalid_repo = double(name: 'mercurialrepo', slug: 'mercurialrepo', owner: 'asd', full_name: 'asd/mercurialrepo', clone_url: 'http://test.host/demo/mercurialrepo.git', 'valid?' => false) allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + end - assign_session_tokens + context "when token does not exists" do + let(:random_key) { "pure_random" } + let(:external_bitbucket_auth_url) { "http://fake.bitbucket.host/url" } + + it 'redirects to authorize url with state included' do + allow(SecureRandom).to receive(:base64).and_return(random_key) + allow_next_instance_of(OAuth2::Client) do |client| + allow(client).to receive_message_chain(:auth_code, :authorize_url) + .with(redirect_uri: users_import_bitbucket_callback_url, state: random_key) + .and_return(external_bitbucket_auth_url) + end + + get :status, format: :json + + expect(controller).to redirect_to(external_bitbucket_auth_url) + end end - it_behaves_like 'import controller status' do + context "when token is valid" do before do - allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + assign_session_tokens end - let(:repo) { @repo } - let(:repo_id) { @repo.full_name } - let(:import_source) { @repo.full_name } - let(:provider_name) { 'bitbucket' } - let(:client_repos_field) { :repos } - end + it_behaves_like 'import controller status' do + before do + allow(controller).to receive(:provider_url).and_return('http://demobitbucket.org') + end - it 'returns invalid repos' do - allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo]) + let(:repo) { @repo } + let(:repo_id) { @repo.full_name } + let(:import_source) { @repo.full_name } + let(:provider_name) { 'bitbucket' } + let(:client_repos_field) { :repos } + end - get :status, format: :json + it 'returns invalid repos' do + allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo, @invalid_repo]) - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['incompatible_repos'].length).to eq(1) - expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name) - expect(json_response['provider_repos'].length).to eq(1) - expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name) - end + get :status, format: :json - context 'when filtering' do - let(:filter) { 'test' } - let(:expected_filter) { 'test' } + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['incompatible_repos'].length).to eq(1) + expect(json_response.dig("incompatible_repos", 0, "id")).to eq(@invalid_repo.full_name) + expect(json_response['provider_repos'].length).to eq(1) + expect(json_response.dig("provider_repos", 0, "id")).to eq(@repo.full_name) + end - subject { get :status, params: { filter: filter }, as: :json } + context 'when filtering' do + let(:filter) { 'test' } + let(:expected_filter) { 'test' } - it 'passes sanitized filter param to bitbucket client' do - expect_next_instance_of(Bitbucket::Client) do |client| - expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo]) - end + subject { get :status, params: { filter: filter }, as: :json } - subject + it 'passes sanitized filter param to bitbucket client' do + expect_next_instance_of(Bitbucket::Client) do |client| + expect(client).to receive(:repos).with(filter: expected_filter).and_return([@repo]) + end + + subject + end end end end diff --git a/spec/controllers/import/github_controller_spec.rb b/spec/controllers/import/github_controller_spec.rb index fd380f9b763..ef66124bff1 100644 --- a/spec/controllers/import/github_controller_spec.rb +++ b/spec/controllers/import/github_controller_spec.rb @@ -82,11 +82,33 @@ RSpec.describe Import::GithubController do expect(controller).to redirect_to(new_import_url) expect(flash[:alert]).to eq('Access denied to your GitHub account.') end + + it "includes namespace_id from session if it is present" do + namespace_id = 1 + session[:namespace_id] = 1 + + get :callback, params: { state: valid_auth_state } + + expect(controller).to redirect_to(status_import_github_url(namespace_id: namespace_id)) + end end end describe "POST personal_access_token" do it_behaves_like 'a GitHub-ish import controller: POST personal_access_token' + + it 'passes namespace_id param as query param if it was present' do + namespace_id = 5 + status_import_url = public_send("status_import_#{provider}_url", { namespace_id: namespace_id }) + + allow_next_instance_of(Gitlab::LegacyGithubImport::Client) do |client| + allow(client).to receive(:user).and_return(true) + end + + post :personal_access_token, params: { personal_access_token: 'some-token', namespace_id: 5 } + + expect(controller).to redirect_to(status_import_url) + end end describe "GET status" do @@ -258,7 +280,9 @@ RSpec.describe Import::GithubController do context 'when user input contains colons and spaces' do before do - allow(controller).to receive(:client_repos).and_return([]) + allow_next_instance_of(Gitlab::GithubImport::Client) do |client| + allow(client).to receive(:search_repos_by_name).and_return(items: []) + end end it 'sanitizes user input' do @@ -293,6 +317,22 @@ RSpec.describe Import::GithubController do end describe "GET realtime_changes" do + let(:user) { create(:user) } + it_behaves_like 'a GitHub-ish import controller: GET realtime_changes' + + before do + assign_session_token(provider) + end + + it 'includes stats in response' do + create(:project, import_type: provider, namespace: user.namespace, import_status: :finished, import_source: 'example/repo') + + get :realtime_changes + + expect(json_response[0]).to include('stats') + expect(json_response[0]['stats']).to include('fetched') + expect(json_response[0]['stats']).to include('imported') + end end end diff --git a/spec/controllers/jira_connect/events_controller_spec.rb b/spec/controllers/jira_connect/events_controller_spec.rb index 2129b24b2fb..5e90ceb0f9c 100644 --- a/spec/controllers/jira_connect/events_controller_spec.rb +++ b/spec/controllers/jira_connect/events_controller_spec.rb @@ -114,17 +114,6 @@ RSpec.describe JiraConnect::EventsController do base_url: base_url ) end - - context 'when the `jira_connect_installation_update` feature flag is disabled' do - before do - stub_feature_flags(jira_connect_installation_update: false) - end - - it 'does not update the installation', :aggregate_failures do - expect { subject }.not_to change { installation.reload.attributes } - expect(response).to have_gitlab_http_status(:ok) - end - end end context 'when the new base_url is invalid' do diff --git a/spec/controllers/jira_connect/subscriptions_controller_spec.rb b/spec/controllers/jira_connect/subscriptions_controller_spec.rb index f548c1f399d..e9c94f09c99 100644 --- a/spec/controllers/jira_connect/subscriptions_controller_spec.rb +++ b/spec/controllers/jira_connect/subscriptions_controller_spec.rb @@ -75,6 +75,18 @@ RSpec.describe JiraConnect::SubscriptionsController do expect(json_response).to include('login_path' => nil) end end + + context 'with context qsh' do + # The JSON endpoint will be requested by frontend using a JWT that Atlassian provides via Javascript. + # This JWT will likely use a context-qsh because Atlassian don't know for which endpoint it will be used. + # Read more about context JWT here: https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/ + + let(:qsh) { 'context-qsh' } + + specify do + expect(response).to have_gitlab_http_status(:ok) + end + end end end end @@ -102,7 +114,7 @@ RSpec.describe JiraConnect::SubscriptionsController do end context 'with valid JWT' do - let(:claims) { { iss: installation.client_key, sub: 1234 } } + let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } } let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) } let(:jira_user) { { 'groups' => { 'items' => [{ 'name' => jira_group_name }] } } } let(:jira_group_name) { 'site-admins' } @@ -158,7 +170,7 @@ RSpec.describe JiraConnect::SubscriptionsController do .stub_request(:get, "#{installation.base_url}/rest/api/3/user?accountId=1234&expand=groups") .to_return(body: jira_user.to_json, status: 200, headers: { 'Content-Type' => 'application/json' }) - delete :destroy, params: { jwt: jwt, id: subscription.id } + delete :destroy, params: { jwt: jwt, id: subscription.id, format: :json } end context 'without JWT' do @@ -170,7 +182,7 @@ RSpec.describe JiraConnect::SubscriptionsController do end context 'with valid JWT' do - let(:claims) { { iss: installation.client_key, sub: 1234 } } + let(:claims) { { iss: installation.client_key, sub: 1234, qsh: '123' } } let(:jwt) { Atlassian::Jwt.encode(claims, installation.shared_secret) } it 'deletes the subscription' do diff --git a/spec/controllers/oauth/jira/authorizations_controller_spec.rb b/spec/controllers/oauth/jira/authorizations_controller_spec.rb deleted file mode 100644 index f4a335b30f4..00000000000 --- a/spec/controllers/oauth/jira/authorizations_controller_spec.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Oauth::Jira::AuthorizationsController do - describe 'GET new' do - it 'redirects to OAuth authorization with correct params' do - get :new, params: { client_id: 'client-123', scope: 'foo', redirect_uri: 'http://example.com/' } - - expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', - response_type: 'code', - scope: 'foo', - redirect_uri: oauth_jira_callback_url)) - end - - it 'replaces the GitHub "repo" scope with "api"' do - get :new, params: { client_id: 'client-123', scope: 'repo', redirect_uri: 'http://example.com/' } - - expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', - response_type: 'code', - scope: 'api', - redirect_uri: oauth_jira_callback_url)) - end - end - - describe 'GET callback' do - it 'redirects to redirect_uri on session with code param' do - session['redirect_uri'] = 'http://example.com' - - get :callback, params: { code: 'hash-123' } - - expect(response).to redirect_to('http://example.com?code=hash-123') - end - - it 'redirects to redirect_uri on session with code param preserving existing query' do - session['redirect_uri'] = 'http://example.com?foo=bar' - - get :callback, params: { code: 'hash-123' } - - expect(response).to redirect_to('http://example.com?foo=bar&code=hash-123') - end - end - - describe 'POST access_token' do - it 'returns oauth params in a format Jira expects' do - expect_any_instance_of(Doorkeeper::Request::AuthorizationCode).to receive(:authorize) do - double(status: :ok, body: { 'access_token' => 'fake-123', 'scope' => 'foo', 'token_type' => 'bar' }) - end - - post :access_token, params: { code: 'code-123', client_id: 'client-123', client_secret: 'secret-123' } - - expect(response.body).to eq('access_token=fake-123&scope=foo&token_type=bar') - end - end -end diff --git a/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb new file mode 100644 index 00000000000..496ef7859f9 --- /dev/null +++ b/spec/controllers/oauth/jira_dvcs/authorizations_controller_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Oauth::JiraDvcs::AuthorizationsController do + describe 'GET new' do + it 'redirects to OAuth authorization with correct params' do + get :new, params: { client_id: 'client-123', scope: 'foo', redirect_uri: 'http://example.com/' } + + expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', + response_type: 'code', + scope: 'foo', + redirect_uri: oauth_jira_dvcs_callback_url)) + end + + it 'replaces the GitHub "repo" scope with "api"' do + get :new, params: { client_id: 'client-123', scope: 'repo', redirect_uri: 'http://example.com/' } + + expect(response).to redirect_to(oauth_authorization_url(client_id: 'client-123', + response_type: 'code', + scope: 'api', + redirect_uri: oauth_jira_dvcs_callback_url)) + end + end + + describe 'GET callback' do + it 'redirects to redirect_uri on session with code param' do + session['redirect_uri'] = 'http://example.com' + + get :callback, params: { code: 'hash-123' } + + expect(response).to redirect_to('http://example.com?code=hash-123') + end + + it 'redirects to redirect_uri on session with code param preserving existing query' do + session['redirect_uri'] = 'http://example.com?foo=bar' + + get :callback, params: { code: 'hash-123' } + + expect(response).to redirect_to('http://example.com?foo=bar&code=hash-123') + end + end + + describe 'POST access_token' do + it 'returns oauth params in a format Jira expects' do + expect_any_instance_of(Doorkeeper::Request::AuthorizationCode).to receive(:authorize) do + double(status: :ok, body: { 'access_token' => 'fake-123', 'scope' => 'foo', 'token_type' => 'bar' }) + end + + post :access_token, params: { code: 'code-123', client_id: 'client-123', client_secret: 'secret-123' } + + expect(response.body).to eq('access_token=fake-123&scope=foo&token_type=bar') + end + end +end diff --git a/spec/controllers/profiles/accounts_controller_spec.rb b/spec/controllers/profiles/accounts_controller_spec.rb index 011528016ce..1b4b67eeaff 100644 --- a/spec/controllers/profiles/accounts_controller_spec.rb +++ b/spec/controllers/profiles/accounts_controller_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Profiles::AccountsController do end end - [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk].each do |provider| + [:twitter, :facebook, :google_oauth2, :gitlab, :github, :bitbucket, :crowd, :auth0, :authentiq, :dingtalk, :alicloud].each do |provider| describe "#{provider} provider" do let(:user) { create(:omniauth_user, provider: provider.to_s) } diff --git a/spec/controllers/profiles/keys_controller_spec.rb b/spec/controllers/profiles/keys_controller_spec.rb index 66f6135df1e..63818337722 100644 --- a/spec/controllers/profiles/keys_controller_spec.rb +++ b/spec/controllers/profiles/keys_controller_spec.rb @@ -17,7 +17,25 @@ RSpec.describe Profiles::KeysController do post :create, params: { key: build(:key, expires_at: expires_at).attributes } end.to change { Key.count }.by(1) - expect(Key.last.expires_at).to be_like_time(expires_at) + key = Key.last + expect(key.expires_at).to be_like_time(expires_at) + expect(key.fingerprint_md5).to be_present + expect(key.fingerprint_sha256).to be_present + end + + context 'with FIPS mode', :fips_mode do + it 'creates a new key without MD5 fingerprint' do + expires_at = 3.days.from_now + + expect do + post :create, params: { key: build(:rsa_key_4096, expires_at: expires_at).attributes } + end.to change { Key.count }.by(1) + + key = Key.last + expect(key.expires_at).to be_like_time(expires_at) + expect(key.fingerprint_md5).to be_nil + expect(key.fingerprint_sha256).to be_present + end end end end diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index b7870a63f9d..7add3a72337 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -46,6 +46,8 @@ RSpec.describe Profiles::PreferencesController do it "changes the user's preferences" do prefs = { color_scheme_id: '1', + diffs_deletion_color: '#123456', + diffs_addition_color: '#abcdef', dashboard: 'stars', theme_id: '2', first_day_of_week: '1', @@ -84,5 +86,27 @@ RSpec.describe Profiles::PreferencesController do expect(response.parsed_body['type']).to eq('alert') end end + + context 'on invalid diffs colors setting' do + it 'responds with error for diffs_deletion_color' do + prefs = { diffs_deletion_color: '#1234567' } + + go params: prefs + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.parsed_body['message']).to eq _('Failed to save preferences.') + expect(response.parsed_body['type']).to eq('alert') + end + + it 'responds with error for diffs_addition_color' do + prefs = { diffs_addition_color: '#1234567' } + + go params: prefs + + expect(response).to have_gitlab_http_status(:bad_request) + expect(response.parsed_body['message']).to eq _('Failed to save preferences.') + expect(response.parsed_body['type']).to eq('alert') + end + end end end diff --git a/spec/controllers/profiles/two_factor_auths_controller_spec.rb b/spec/controllers/profiles/two_factor_auths_controller_spec.rb index 47086ccdd2c..33cba675777 100644 --- a/spec/controllers/profiles/two_factor_auths_controller_spec.rb +++ b/spec/controllers/profiles/two_factor_auths_controller_spec.rb @@ -104,17 +104,29 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(subject).to receive(:build_qr_code).and_return(code) get :show - expect(assigns[:qr_code]).to eq code + expect(assigns[:qr_code]).to eq(code) end - it 'generates a unique otp_secret every time the page is loaded' do - expect(User).to receive(:generate_otp_secret).with(32).and_call_original.twice + it 'generates a single otp_secret with multiple page loads', :freeze_time do + expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once + + user.update!(otp_secret: nil, otp_secret_expires_at: nil) 2.times do get :show end end + it 'generates a new otp_secret once the ttl has expired' do + expect(User).to receive(:generate_otp_secret).with(32).and_call_original.once + + user.update!(otp_secret: "FT7KAVNU63YZH7PBRVPVL7CPSAENXY25", otp_secret_expires_at: 2.minutes.from_now) + + travel_to(10.minutes.from_now) do + get :show + end + end + it_behaves_like 'user must first verify their primary email address' do let(:go) { get :show } end @@ -183,7 +195,12 @@ RSpec.describe Profiles::TwoFactorAuthsController do expect(subject).to receive(:build_qr_code).and_return(code) go - expect(assigns[:qr_code]).to eq code + expect(assigns[:qr_code]).to eq(code) + end + + it 'assigns account_string' do + go + expect(assigns[:account_string]).to eq("#{Gitlab.config.gitlab.host}:#{user.email}") end it 'renders show' do diff --git a/spec/controllers/projects/artifacts_controller_spec.rb b/spec/controllers/projects/artifacts_controller_spec.rb index f410c16b30b..d51880b282d 100644 --- a/spec/controllers/projects/artifacts_controller_spec.rb +++ b/spec/controllers/projects/artifacts_controller_spec.rb @@ -323,6 +323,7 @@ RSpec.describe Projects::ArtifactsController do subject expect(response).to have_gitlab_http_status(:ok) + expect(response.headers['Gitlab-Workhorse-Detect-Content-Type']).to eq('true') expect(send_data).to start_with('artifacts-entry:') expect(params.keys).to eq(%w(Archive Entry)) @@ -338,7 +339,7 @@ RSpec.describe Projects::ArtifactsController do def params @params ||= begin - base64_params = send_data.sub(/\Aartifacts\-entry:/, '') + base64_params = send_data.delete_prefix('artifacts-entry:') Gitlab::Json.parse(Base64.urlsafe_decode64(base64_params)) end end diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index ea22e6b6f10..1580ad9361d 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -688,21 +688,23 @@ RSpec.describe Projects::BranchesController do end context 'when gitaly is not available' do + let(:request) { get :index, format: :html, params: { namespace_id: project.namespace, project_id: project } } + before do allow_next_instance_of(Gitlab::GitalyClient::RefService) do |ref_service| allow(ref_service).to receive(:local_branches).and_raise(GRPC::DeadlineExceeded) end - - get :index, format: :html, params: { - namespace_id: project.namespace, project_id: project - } end - it 'returns with a status 200' do - expect(response).to have_gitlab_http_status(:ok) + it 'returns with a status 503' do + request + + expect(response).to have_gitlab_http_status(:service_unavailable) end it 'sets gitaly_unavailable variable' do + request + expect(assigns[:gitaly_unavailable]).to be_truthy end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 72fee40a6e9..a72c98552a5 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -60,6 +60,22 @@ RSpec.describe Projects::CommitController do end end + context 'with valid page' do + it 'responds with 200' do + go(id: commit.id, page: 1) + + expect(response).to be_ok + end + end + + context 'with invalid page' do + it 'does not return an error' do + go(id: commit.id, page: ['invalid']) + + expect(response).to be_ok + end + end + it 'handles binary files' do go(id: TestEnv::BRANCH_SHA['binary-encoding'], format: 'html') @@ -212,6 +228,21 @@ RSpec.describe Projects::CommitController do end end + context 'when the revert commit is missing' do + it 'renders the 404 page' do + post(:revert, + params: { + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', + id: '1234567890' + }) + + expect(response).not_to be_successful + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when the revert was successful' do it 'redirects to the commits page' do post(:revert, @@ -269,6 +300,21 @@ RSpec.describe Projects::CommitController do end end + context 'when the cherry-pick commit is missing' do + it 'renders the 404 page' do + post(:cherry_pick, + params: { + namespace_id: project.namespace, + project_id: project, + start_branch: 'master', + id: '1234567890' + }) + + expect(response).not_to be_successful + expect(response).to have_gitlab_http_status(:not_found) + end + end + context 'when the cherry-pick was successful' do it 'redirects to the commits page' do post(:cherry_pick, diff --git a/spec/controllers/projects/compare_controller_spec.rb b/spec/controllers/projects/compare_controller_spec.rb index 62b93a2728b..9821618df8d 100644 --- a/spec/controllers/projects/compare_controller_spec.rb +++ b/spec/controllers/projects/compare_controller_spec.rb @@ -58,11 +58,13 @@ RSpec.describe Projects::CompareController do from_project_id: from_project_id, from: from_ref, to: to_ref, - w: whitespace + w: whitespace, + page: page } end let(:whitespace) { nil } + let(:page) { nil } context 'when the refs exist in the same project' do context 'when we set the white space param' do @@ -196,6 +198,34 @@ RSpec.describe Projects::CompareController do expect(response).to have_gitlab_http_status(:found) end end + + context 'when page is valid' do + let(:from_project_id) { nil } + let(:from_ref) { '08f22f25' } + let(:to_ref) { '66eceea0' } + let(:page) { 1 } + + it 'shows the diff' do + show_request + + expect(response).to be_successful + expect(assigns(:diffs).diff_files.first).to be_present + expect(assigns(:commits).length).to be >= 1 + end + end + + context 'when page is not valid' do + let(:from_project_id) { nil } + let(:from_ref) { '08f22f25' } + let(:to_ref) { '66eceea0' } + let(:page) { ['invalid'] } + + it 'does not return an error' do + show_request + + expect(response).to be_successful + end + end end describe 'GET diff_for_path' do diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index fdfc21887a6..f4cad5790a3 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -254,38 +254,54 @@ RSpec.describe Projects::EnvironmentsController do end describe 'PATCH #stop' do + subject { patch :stop, params: environment_params(format: :json) } + context 'when env not available' do it 'returns 404' do allow_any_instance_of(Environment).to receive(:available?) { false } - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:not_found) end end context 'when stop action' do - it 'returns action url' do + it 'returns action url for single stop action' do action = create(:ci_build, :manual) allow_any_instance_of(Environment) - .to receive_messages(available?: true, stop_with_action!: action) + .to receive_messages(available?: true, stop_with_actions!: [action]) - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq( { 'redirect_url' => project_job_url(project, action) }) end + + it 'returns environment url for multiple stop actions' do + actions = create_list(:ci_build, 2, :manual) + + allow_any_instance_of(Environment) + .to receive_messages(available?: true, stop_with_actions!: actions) + + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to eq( + { 'redirect_url' => + project_environment_url(project, environment) }) + end end context 'when no stop action' do it 'returns env url' do allow_any_instance_of(Environment) - .to receive_messages(available?: true, stop_with_action!: nil) + .to receive_messages(available?: true, stop_with_actions!: nil) - patch :stop, params: environment_params(format: :json) + subject expect(response).to have_gitlab_http_status(:ok) expect(json_response).to eq( diff --git a/spec/controllers/projects/group_links_controller_spec.rb b/spec/controllers/projects/group_links_controller_spec.rb index ea15d483c90..96705d82ac5 100644 --- a/spec/controllers/projects/group_links_controller_spec.rb +++ b/spec/controllers/projects/group_links_controller_spec.rb @@ -18,136 +18,6 @@ RSpec.describe Projects::GroupLinksController do travel_back end - describe '#create' do - shared_context 'link project to group' do - before do - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group.id, - link_group_access: ProjectGroupLink.default_access - }) - end - end - - context 'when project is not allowed to be shared with a group' do - before do - group.update!(share_with_group_lock: false) - end - - include_context 'link project to group' - - it 'responds with status 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - end - - context 'when user has access to group they want to link project to' do - before do - group.add_developer(user) - end - - include_context 'link project to group' - - it 'links project with selected group' do - expect(group.shared_projects).to include project - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - end - end - - context 'when user doers not have access to group they want to link to' do - include_context 'link project to group' - - it 'renders 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - - it 'does not share project with that group' do - expect(group.shared_projects).not_to include project - end - end - - context 'when user does not have access to the public group' do - let(:group) { create(:group, :public) } - - include_context 'link project to group' - - it 'renders 404' do - expect(response).to have_gitlab_http_status(:not_found) - end - - it 'does not share project with that group' do - expect(group.shared_projects).not_to include project - end - end - - context 'when project group id equal link group id' do - before do - group2.add_developer(user) - - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group2.id, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'does not share project with selected group' do - expect(group2.shared_projects).not_to include project - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - end - end - - context 'when link group id is not present' do - before do - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - expect(flash[:alert]).to eq('Please select a group.') - end - end - - context 'when link is not persisted in the database' do - before do - allow(::Projects::GroupLinks::CreateService).to receive_message_chain(:new, :execute) - .and_return({ status: :error, http_status: 409, message: 'error' }) - - post(:create, params: { - namespace_id: project.namespace, - project_id: project, - link_group_id: group.id, - link_group_access: ProjectGroupLink.default_access - }) - end - - it 'redirects to project group links page' do - expect(response).to redirect_to( - project_project_members_path(project) - ) - expect(flash[:alert]).to eq('error') - end - end - end - describe '#update' do let_it_be(:link) do create( diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 9d3711d8a96..ce0af784cdf 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -148,6 +148,13 @@ RSpec.describe Projects::IssuesController do allow(Kaminari.config).to receive(:default_per_page).and_return(1) end + it 'redirects to last page when out of bounds on non-html requests' do + get :index, params: params.merge(page: last_page + 1), format: 'atom' + + expect(response).to have_gitlab_http_status(:redirect) + expect(response).to redirect_to(action: 'index', format: 'atom', page: last_page, state: 'opened') + end + it 'does not use pagination if disabled' do allow(controller).to receive(:pagination_disabled?).and_return(true) diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index ed68d6a87b8..e9f1232b5e7 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -796,7 +796,7 @@ RSpec.describe Projects::JobsController, :clean_gitlab_redis_shared_state do retried_build = Ci::Build.last - Ci::RetryBuildService.clone_accessors.each do |accessor| + Ci::Build.clone_accessors.each do |accessor| expect(job.read_attribute(accessor)) .to eq(retried_build.read_attribute(accessor)), "Mismatched attribute on \"#{accessor}\". " \ diff --git a/spec/controllers/projects/notes_controller_spec.rb b/spec/controllers/projects/notes_controller_spec.rb index 2df31904380..07874c8a8af 100644 --- a/spec/controllers/projects/notes_controller_spec.rb +++ b/spec/controllers/projects/notes_controller_spec.rb @@ -423,7 +423,21 @@ RSpec.describe Projects::NotesController do end context 'when creating a confidential note' do - let(:extra_request_params) { { format: :json } } + let(:project) { create(:project) } + let(:note_params) do + { note: note_text, noteable_id: issue.id, noteable_type: 'Issue' }.merge(extra_note_params) + end + + let(:request_params) do + { + note: note_params, + namespace_id: project.namespace, + project_id: project, + target_type: 'issue', + target_id: issue.id, + format: :json + } + end context 'when `confidential` parameter is not provided' do it 'sets `confidential` to `false` in JSON response' do diff --git a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb index a655c742973..fc741d0f3f6 100644 --- a/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb +++ b/spec/controllers/projects/packages/infrastructure_registry_controller_spec.rb @@ -41,17 +41,5 @@ RSpec.describe Projects::Packages::InfrastructureRegistryController do it_behaves_like 'returning response status', :not_found end - - context 'with package file pending destruction' do - let_it_be(:package_file_pending_destruction) { create(:package_file, :pending_destruction, package: terraform_module) } - - let(:terraform_module_package_file) { terraform_module.package_files.first } - - it 'does not return them' do - subject - - expect(assigns(:package_files)).to contain_exactly(terraform_module_package_file) - end - end end end diff --git a/spec/controllers/projects/pipelines/tests_controller_spec.rb b/spec/controllers/projects/pipelines/tests_controller_spec.rb index e6ff3a487ac..113781bab7c 100644 --- a/spec/controllers/projects/pipelines/tests_controller_spec.rb +++ b/spec/controllers/projects/pipelines/tests_controller_spec.rb @@ -40,28 +40,56 @@ RSpec.describe Projects::Pipelines::TestsController do let(:suite_name) { 'test' } let(:build_ids) { pipeline.latest_builds.pluck(:id) } - before do - build = main_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::TestFailureHistoryService.new(main_pipeline).execute + context 'when artifacts are expired' do + before do + pipeline.job_artifacts.first.update!(expire_at: Date.yesterday) + end + + it 'renders not_found errors', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['errors']).to eq('Test report artifacts have expired') + end + + context 'when ci_test_report_artifacts_expired is disabled' do + before do + stub_feature_flags(ci_test_report_artifacts_expired: false) + end + it 'renders test suite', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('test') + end + end end - it 'renders test suite data' do - get_tests_show_json(build_ids) - - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['name']).to eq('test') - - # Each test failure in this pipeline has a matching failure in the default branch - recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] } - expect(recent_failures).to eq([ - { 'count' => 1, 'base_branch' => 'master' }, - { 'count' => 1, 'base_branch' => 'master' }, - { 'count' => 1, 'base_branch' => 'master' } - ]) + context 'when artifacts are not expired' do + before do + build = main_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::TestFailureHistoryService.new(main_pipeline).execute + end + + it 'renders test suite data', :aggregate_failures do + get_tests_show_json(build_ids) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['name']).to eq('test') + expect(json_response['artifacts_expired']).to be_falsey + + # Each test failure in this pipeline has a matching failure in the default branch + recent_failures = json_response['test_cases'].map { |tc| tc['recent_failures'] } + expect(recent_failures).to eq([ + { 'count' => 1, 'base_branch' => 'master' }, + { 'count' => 1, 'base_branch' => 'master' }, + { 'count' => 1, 'base_branch' => 'master' } + ]) + end end end diff --git a/spec/controllers/projects/services_controller_spec.rb b/spec/controllers/projects/services_controller_spec.rb index 35e5422d072..7e96c59fbb1 100644 --- a/spec/controllers/projects/services_controller_spec.rb +++ b/spec/controllers/projects/services_controller_spec.rb @@ -359,10 +359,9 @@ RSpec.describe Projects::ServicesController do def prometheus_integration_as_data pi = project.prometheus_integration.reload attrs = pi.attributes.except('encrypted_properties', - 'encrypted_properties_iv', - 'encrypted_properties_tmp') + 'encrypted_properties_iv') - [attrs, pi.encrypted_properties_tmp] + [attrs, pi.properties] end end diff --git a/spec/controllers/projects/static_site_editor_controller_spec.rb b/spec/controllers/projects/static_site_editor_controller_spec.rb index 26161b5fb5c..e1f25589eeb 100644 --- a/spec/controllers/projects/static_site_editor_controller_spec.rb +++ b/spec/controllers/projects/static_site_editor_controller_spec.rb @@ -76,12 +76,11 @@ RSpec.describe Projects::StaticSiteEditorController do get :show, params: default_params end - it 'increases the views counter' do - expect(Gitlab::UsageDataCounters::StaticSiteEditorCounter).to have_received(:increment_views_count) - end + it 'redirects to the Web IDE' do + get :show, params: default_params - it 'renders the edit page' do - expect(response).to render_template(:show) + expected_path_regex = %r[-/ide/project/#{project.full_path}/edit/master/-/README.md] + expect(response).to redirect_to(expected_path_regex) end it 'assigns ref and path variables' do @@ -96,62 +95,6 @@ RSpec.describe Projects::StaticSiteEditorController do expect(response).to have_gitlab_http_status(:not_found) end end - - context 'when invalid config file' do - let(:service_response) { ServiceResponse.error(message: 'invalid') } - - it 'redirects to project page and flashes error message' do - expect(response).to redirect_to(project_path(project)) - expect(controller).to set_flash[:alert].to('invalid') - end - end - - context 'with a service response payload containing multiple data types' do - let(:data) do - { - a_string: 'string', - an_array: [ - { - foo: 'bar' - } - ], - an_integer: 123, - a_hash: { - a_deeper_hash: { - foo: 'bar' - } - }, - a_boolean: true, - a_nil: nil - } - end - - let(:assigns_data) { assigns(:data) } - - it 'leaves data values which are strings as strings' do - expect(assigns_data[:a_string]).to eq('string') - end - - it 'leaves data values which are integers as integers' do - expect(assigns_data[:an_integer]).to eq(123) - end - - it 'serializes data values which are booleans to JSON' do - expect(assigns_data[:a_boolean]).to eq('true') - end - - it 'serializes data values which are arrays to JSON' do - expect(assigns_data[:an_array]).to eq('[{"foo":"bar"}]') - end - - 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 end diff --git a/spec/controllers/projects/todos_controller_spec.rb b/spec/controllers/projects/todos_controller_spec.rb index 9a73417ffdb..d87f4258b9c 100644 --- a/spec/controllers/projects/todos_controller_spec.rb +++ b/spec/controllers/projects/todos_controller_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Projects::TodosController do let(:issue) { create(:issue, project: project) } let(:merge_request) { create(:merge_request, source_project: project) } - let(:design) { create(:design, project: project, issue: issue) } + let(:design) { create(:design, :with_versions, project: project, issue: issue) } let(:parent) { project } shared_examples 'issuable todo actions' do diff --git a/spec/controllers/projects/usage_quotas_controller_spec.rb b/spec/controllers/projects/usage_quotas_controller_spec.rb index 6125ba13f96..2831de00348 100644 --- a/spec/controllers/projects/usage_quotas_controller_spec.rb +++ b/spec/controllers/projects/usage_quotas_controller_spec.rb @@ -4,17 +4,44 @@ require 'spec_helper' RSpec.describe Projects::UsageQuotasController do let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, namespace: user.namespace) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } describe 'GET #index' do render_views - it 'does not render search settings partial' do + subject { get(:index, params: { namespace_id: project.namespace, project_id: project }) } + + before do sign_in(user) - get(:index, params: { namespace_id: user.namespace, project_id: project }) + end + + context 'when user does not have read_usage_quotas permission' do + before do + project.add_developer(user) + end + + it 'renders not_found' do + subject + + expect(response).to render_template('errors/not_found') + expect(response).not_to render_template('shared/search_settings') + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when user has read_usage_quotas permission' do + before do + project.add_maintainer(user) + end + + it 'renders index with 200 status code' do + subject - expect(response).to render_template('index') - expect(response).not_to render_template('shared/search_settings') + expect(response).to render_template('index') + expect(response).not_to render_template('shared/search_settings') + expect(response).to have_gitlab_http_status(:ok) + end end end end diff --git a/spec/controllers/projects_controller_spec.rb b/spec/controllers/projects_controller_spec.rb index c098ea71f7a..07bd198137a 100644 --- a/spec/controllers/projects_controller_spec.rb +++ b/spec/controllers/projects_controller_spec.rb @@ -473,28 +473,6 @@ RSpec.describe ProjectsController do end end end - - context 'with new_project_sast_enabled', :experiment do - let(:params) do - { - path: 'foo', - description: 'bar', - namespace_id: user.namespace.id, - initialize_with_sast: '1' - } - end - - it 'tracks an event on project creation' do - expect(experiment(:new_project_sast_enabled)).to track(:created, - property: 'blank', - checked: true, - project: an_instance_of(Project), - namespace: user.namespace - ).on_next_instance.with_context(user: user) - - post :create, params: { project: params } - end - end end describe 'GET edit' do @@ -1159,16 +1137,15 @@ RSpec.describe ProjectsController do context 'when gitaly is unavailable' do before do expect_next_instance_of(TagsFinder) do |finder| - allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError) + allow(finder).to receive(:execute).and_raise(Gitlab::Git::CommandError, 'something went wrong') end end - it 'gets an empty list of tags' do + it 'responds with 503 error' do get :refs, params: { namespace_id: project.namespace, id: project, ref: "123456" } - expect(json_response["Branches"]).to include("master") - expect(json_response["Tags"]).to eq([]) - expect(json_response["Commits"]).to include("123456") + expect(response).to have_gitlab_http_status(:service_unavailable) + expect(json_response['error']).to eq 'Unable to load refs' end end @@ -1466,14 +1443,15 @@ RSpec.describe ProjectsController do end describe '#download_export', :clean_gitlab_redis_rate_limiting do + let(:project) { create(:project, :with_export, service_desk_enabled: false) } let(:action) { :download_export } context 'object storage enabled' do context 'when project export is enabled' do - it 'returns 302' do + it 'returns 200' do get action, params: { namespace_id: project.namespace, id: project } - expect(response).to have_gitlab_http_status(:found) + expect(response).to have_gitlab_http_status(:ok) end end @@ -1513,14 +1491,37 @@ RSpec.describe ProjectsController do expect(response.body).to eq('This endpoint has been requested too many times. Try again later.') expect(response).to have_gitlab_http_status(:too_many_requests) end + end + + context 'applies correct scope when throttling', :clean_gitlab_redis_rate_limiting do + before do + stub_application_setting(project_download_export_limit: 1) + end - it 'applies correct scope when throttling' do + it 'applies throttle per namespace' do expect(Gitlab::ApplicationRateLimiter) .to receive(:throttled?) - .with(:project_download_export, scope: [user, project]) + .with(:project_download_export, scope: [user, project.namespace]) post action, params: { namespace_id: project.namespace, id: project } end + + it 'throttles downloads within same namespaces' do + # simulate prior request to the same namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, scope: [user, project.namespace]) + + get action, params: { namespace_id: project.namespace, id: project } + expect(response).to have_gitlab_http_status(:too_many_requests) + end + + it 'allows downloads from different namespaces' do + # simulate prior request to a different namespace, which increments the rate limit counter for that scope + Gitlab::ApplicationRateLimiter.throttled?(:project_download_export, + scope: [user, create(:project, :with_export).namespace]) + + get action, params: { namespace_id: project.namespace, id: project } + expect(response).to have_gitlab_http_status(:ok) + end end end end diff --git a/spec/controllers/search_controller_spec.rb b/spec/controllers/search_controller_spec.rb index 9482448fc03..4abcd414e51 100644 --- a/spec/controllers/search_controller_spec.rb +++ b/spec/controllers/search_controller_spec.rb @@ -211,6 +211,7 @@ RSpec.describe SearchController do :global_search_merge_requests_tab | 'merge_requests' :global_search_wiki_tab | 'wiki_blobs' :global_search_commits_tab | 'commits' + :global_search_users_tab | 'users' end with_them do diff --git a/spec/controllers/sessions_controller_spec.rb b/spec/controllers/sessions_controller_spec.rb index 03d053e6f97..877ca7cd6c6 100644 --- a/spec/controllers/sessions_controller_spec.rb +++ b/spec/controllers/sessions_controller_spec.rb @@ -193,6 +193,10 @@ RSpec.describe SessionsController do end context 'with reCAPTCHA' do + before do + stub_feature_flags(arkose_labs_login_challenge: false) + end + def unsuccesful_login(user_params, sesion_params: {}) # Without this, `verify_recaptcha` arbitrarily returns true in test env Recaptcha.configuration.skip_verify_env.delete('test') @@ -234,7 +238,7 @@ RSpec.describe SessionsController do unsuccesful_login(user_params) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end @@ -258,7 +262,7 @@ RSpec.describe SessionsController do it 'displays an error when the reCAPTCHA is not solved' do unsuccesful_login(user_params, sesion_params: { failed_login_attempts: 6 }) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end @@ -278,7 +282,7 @@ RSpec.describe SessionsController do it 'displays an error when the reCAPTCHA is not solved' do unsuccesful_login(user_params) - expect(response).to render_template(:new) + expect(response).to redirect_to new_user_session_path expect(flash[:alert]).to include _('There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.') expect(subject.current_user).to be_nil end diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 8442c214cd3..ffcd759435c 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -701,6 +701,24 @@ RSpec.describe UploadsController do end end end + + context 'when viewing alert metric images' do + let!(:user) { create(:user) } + let!(:project) { create(:project) } + let(:alert) { create(:alert_management_alert, project: project) } + let(:metric_image) { create(:alert_metric_image, alert: alert) } + + before do + project.add_developer(user) + sign_in(user) + end + + it "responds with status 200" do + get :show, params: { model: "alert_management_metric_image", mounted_as: 'file', id: metric_image.id, filename: metric_image.filename } + + expect(response).to have_gitlab_http_status(:ok) + end + end end def post_authorize(verified: true) diff --git a/spec/db/migration_spec.rb b/spec/db/migration_spec.rb new file mode 100644 index 00000000000..ac649925751 --- /dev/null +++ b/spec/db/migration_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Migrations Validation' do + using RSpec::Parameterized::TableSyntax + + # The range describes the timestamps that given migration helper can be used + let(:all_migration_classes) do + { + 2022_01_26_21_06_58.. => Gitlab::Database::Migration[2.0], + 2021_09_01_15_33_24.. => Gitlab::Database::Migration[1.0], + 2021_05_31_05_39_16..2021_09_01_15_33_24 => ActiveRecord::Migration[6.1], + ..2021_05_31_05_39_16 => ActiveRecord::Migration[6.0] + } + end + + where(:migration) do + Gitlab::Database.database_base_models.flat_map do |_, model| + model.connection.migration_context.migrations + end.uniq + end + + with_them do + let(:migration_instance) { migration.send(:migration) } + let(:allowed_migration_classes) { all_migration_classes.select { |r, _| r.include?(migration.version) }.values } + + it 'uses one of the allowed migration classes' do + expect(allowed_migration_classes).to include(be > migration_instance.class) + end + end +end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index 177a565bbc0..04f73050ea5 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -10,6 +10,10 @@ RSpec.describe 'Database schema' do let(:tables) { connection.tables } let(:columns_name_with_jsonb) { retrieve_columns_name_with_jsonb } + IGNORED_INDEXES_ON_FKS = { + issues: %w[work_item_type_id] + }.with_indifferent_access.freeze + # List of columns historically missing a FK, don't add more columns # See: https://docs.gitlab.com/ee/development/foreign_keys.html#naming-foreign-keys IGNORED_FK_COLUMNS = { @@ -18,7 +22,7 @@ RSpec.describe 'Database schema' do approvals: %w[user_id], approver_groups: %w[target_id], approvers: %w[target_id user_id], - analytics_cycle_analytics_aggregations: %w[last_full_run_issues_id last_full_run_merge_requests_id last_incremental_issues_id last_incremental_merge_requests_id], + analytics_cycle_analytics_aggregations: %w[last_full_issues_id last_full_merge_requests_id last_incremental_issues_id last_full_run_issues_id last_full_run_merge_requests_id last_incremental_merge_requests_id], analytics_cycle_analytics_merge_request_stage_events: %w[author_id group_id merge_request_id milestone_id project_id stage_event_hash_id state_id], analytics_cycle_analytics_issue_stage_events: %w[author_id group_id issue_id milestone_id project_id stage_event_hash_id state_id], audit_events: %w[author_id entity_id target_id], @@ -115,6 +119,7 @@ RSpec.describe 'Database schema' do columns.first.chomp end foreign_keys_columns = all_foreign_keys.map(&:column) + required_indexed_columns = foreign_keys_columns - ignored_index_columns(table) # Add the primary key column to the list of indexed columns because # postgres and mysql both automatically create an index on the primary @@ -122,7 +127,7 @@ RSpec.describe 'Database schema' do # automatically generated indexes (like the primary key index). first_indexed_column.push(primary_key_column) - expect(first_indexed_column.uniq).to include(*foreign_keys_columns) + expect(first_indexed_column.uniq).to include(*required_indexed_columns) end end @@ -175,18 +180,16 @@ RSpec.describe 'Database schema' do 'PrometheusAlert' => %w[operator] }.freeze - context 'for enums' do - ApplicationRecord.descendants.each do |model| - # skip model if it is an abstract class as it would not have an associated DB table - next if model.abstract_class? + context 'for enums', :eager_load do + # skip model if it is an abstract class as it would not have an associated DB table + let(:models) { ApplicationRecord.descendants.reject(&:abstract_class?) } - describe model do - let(:ignored_enums) { ignored_limit_enums(model.name) } - let(:enums) { model.defined_enums.keys - ignored_enums } + it 'uses smallint for enums in all models', :aggregate_failures do + models.each do |model| + ignored_enums = ignored_limit_enums(model.name) + enums = model.defined_enums.keys - ignored_enums - it 'uses smallint for enums' do - expect(model).to use_smallint_for_enums(enums) - end + expect(model).to use_smallint_for_enums(enums) end end end @@ -305,8 +308,12 @@ RSpec.describe 'Database schema' do @models_by_table_name ||= ApplicationRecord.descendants.reject(&:abstract_class).group_by(&:table_name) end - def ignored_fk_columns(column) - IGNORED_FK_COLUMNS.fetch(column, []) + def ignored_fk_columns(table) + IGNORED_FK_COLUMNS.fetch(table, []) + end + + def ignored_index_columns(table) + IGNORED_INDEXES_ON_FKS.fetch(table, []) end def ignored_limit_enums(model) diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb index 5e7ff34463c..fa4fdf805ec 100644 --- a/spec/deprecation_toolkit_env.rb +++ b/spec/deprecation_toolkit_env.rb @@ -56,11 +56,8 @@ module DeprecationToolkitEnv # In this case, we recommend to add a silence together with an issue to patch or update # the dependency causing the problem. # See https://gitlab.com/gitlab-org/gitlab/-/commit/aea37f506bbe036378998916d374966c031bf347#note_647515736 - # - # - ruby/lib/grpc/generic/interceptors.rb: https://gitlab.com/gitlab-org/gitlab/-/issues/339305 def self.allowed_kwarg_warning_paths %w[ - ruby/lib/grpc/generic/interceptors.rb ] end diff --git a/spec/events/ci/pipeline_created_event_spec.rb b/spec/events/ci/pipeline_created_event_spec.rb new file mode 100644 index 00000000000..191c2e450dc --- /dev/null +++ b/spec/events/ci/pipeline_created_event_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::PipelineCreatedEvent do + using RSpec::Parameterized::TableSyntax + + where(:data, :valid) do + { pipeline_id: 1 } | true + { pipeline_id: nil } | false + { pipeline_id: "test" } | false + {} | false + { job_id: 1 } | false + end + + with_them do + let(:event) { described_class.new(data: data) } + + it 'validates the data according to the schema' do + if valid + expect { event }.not_to raise_error + else + expect { event }.to raise_error(Gitlab::EventStore::InvalidEvent) + end + end + end +end diff --git a/spec/experiments/ios_specific_templates_experiment_spec.rb b/spec/experiments/ios_specific_templates_experiment_spec.rb new file mode 100644 index 00000000000..4d02381dbde --- /dev/null +++ b/spec/experiments/ios_specific_templates_experiment_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe IosSpecificTemplatesExperiment do + subject do + described_class.new(actor: user, project: project) do |e| + e.candidate { true } + end.run + end + + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :auto_devops_disabled) } + + let!(:project_setting) { create(:project_setting, project: project, target_platforms: target_platforms) } + let(:target_platforms) { %w(ios) } + + before do + stub_experiments(ios_specific_templates: :candidate) + project.add_developer(user) if user + end + + it { is_expected.to be true } + + describe 'skipping the experiment' do + context 'no actor' do + let_it_be(:user) { nil } + + it { is_expected.to be_falsey } + end + + context 'actor cannot create pipelines' do + before do + project.add_guest(user) + end + + it { is_expected.to be_falsey } + end + + context 'targeting a non iOS platform' do + let(:target_platforms) { [] } + + it { is_expected.to be_falsey } + end + + context 'project has a ci.yaml file' do + before do + allow(project).to receive(:has_ci?).and_return(true) + end + + it { is_expected.to be_falsey } + end + + context 'project has pipelines' do + before do + create(:ci_pipeline, project: project) + end + + it { is_expected.to be_falsey } + end + end +end diff --git a/spec/experiments/new_project_sast_enabled_experiment_spec.rb b/spec/experiments/new_project_sast_enabled_experiment_spec.rb deleted file mode 100644 index 041e5dfa469..00000000000 --- a/spec/experiments/new_project_sast_enabled_experiment_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe NewProjectSastEnabledExperiment do - it "defines the expected behaviors and variants" do - expect(subject.variant_names).to match_array([ - :candidate, - :free_indicator, - :unchecked_candidate, - :unchecked_free_indicator - ]) - end - - it "publishes to the database" do - expect(subject).to receive(:publish_to_database) - - subject.publish - end -end diff --git a/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb new file mode 100644 index 00000000000..596791308a4 --- /dev/null +++ b/spec/experiments/video_tutorials_continuous_onboarding_experiment_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe VideoTutorialsContinuousOnboardingExperiment do + it "defines a control and candidate" do + expect(subject.behaviors.keys).to match_array(%w[control candidate]) + end +end diff --git a/spec/factories/alert_management/metric_images.rb b/spec/factories/alert_management/metric_images.rb new file mode 100644 index 00000000000..d7d8182af3e --- /dev/null +++ b/spec/factories/alert_management/metric_images.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :alert_metric_image, class: 'AlertManagement::MetricImage' do + association :alert, factory: :alert_management_alert + url { generate(:url) } + + trait :local do + file_store { ObjectStorage::Store::LOCAL } + end + + after(:build) do |image| + image.file = fixture_file_upload('spec/fixtures/rails_sample.jpg', 'image/jpg') + end + end +end diff --git a/spec/factories/application_settings.rb b/spec/factories/application_settings.rb index 8ac003d0a98..c28d3c20a86 100644 --- a/spec/factories/application_settings.rb +++ b/spec/factories/application_settings.rb @@ -4,5 +4,6 @@ FactoryBot.define do factory :application_setting do default_projects_limit { 42 } import_sources { [] } + restricted_visibility_levels { [] } end end diff --git a/spec/factories/ci/builds.rb b/spec/factories/ci/builds.rb index 011021f6320..56c12d73a3b 100644 --- a/spec/factories/ci/builds.rb +++ b/spec/factories/ci/builds.rb @@ -10,7 +10,7 @@ FactoryBot.define do options do { - image: 'ruby:2.7', + image: 'image:1.0', services: ['postgres'], script: ['ls -a'] } @@ -175,6 +175,58 @@ FactoryBot.define do end end + trait :prepare_staging do + name { 'prepare staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'prepare' } + } + end + + set_expanded_environment_name + end + + trait :start_staging do + name { 'start staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'start' } + } + end + + set_expanded_environment_name + end + + trait :stop_staging do + name { 'stop staging' } + environment { 'staging' } + + options do + { + script: %w(ls), + environment: { name: 'staging', action: 'stop' } + } + end + + set_expanded_environment_name + end + + trait :set_expanded_environment_name do + after(:build) do |build, evaluator| + build.assign_attributes( + metadata_attributes: { + expanded_environment_name: build.expanded_environment_name + } + ) + end + end + trait :allowed_to_fail do allow_failure { true } end @@ -455,7 +507,7 @@ FactoryBot.define do trait :extended_options do options do { - image: { name: 'ruby:2.7', entrypoint: '/bin/sh' }, + image: { name: 'image:1.0', entrypoint: '/bin/sh' }, services: ['postgres', { name: 'docker:stable-dind', entrypoint: '/bin/sh', command: 'sleep 30', alias: 'docker' }, { name: 'mysql:latest', variables: { MYSQL_ROOT_PASSWORD: 'root123.' } }], script: %w(echo), after_script: %w(ls date), @@ -497,6 +549,22 @@ FactoryBot.define do options { {} } end + trait :coverage_report_cobertura do + options do + { + artifacts: { + expire_in: '7d', + reports: { + coverage_report: { + coverage_format: 'cobertura', + path: 'cobertura.xml' + } + } + } + } + end + end + # TODO: move Security traits to ee_ci_build # https://gitlab.com/gitlab-org/gitlab/-/issues/210486 trait :dast do diff --git a/spec/factories/ci/job_artifacts.rb b/spec/factories/ci/job_artifacts.rb index 77b07c4a404..cdbcdced5f4 100644 --- a/spec/factories/ci/job_artifacts.rb +++ b/spec/factories/ci/job_artifacts.rb @@ -302,6 +302,56 @@ FactoryBot.define do end end + # Bandit reports are correctly de-duplicated when ran in the same pipeline + # as a corresponding semgrep report. + # This report does not include signature tracking. + trait :sast_bandit do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-bandit.json'), 'application/json') + end + end + + # Equivalent Semgrep report for :sast_bandit report. + # This report includes signature tracking. + trait :sast_semgrep_for_bandit do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json'), 'application/json') + end + end + + # Gosec reports are not correctly de-duplicated when ran in the same pipeline + # as a corresponding semgrep report. + # This report includes signature tracking. + trait :sast_gosec do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-gosec.json'), 'application/json') + end + end + + # Equivalent Semgrep report for :sast_gosec report. + # This report includes signature tracking. + trait :sast_semgrep_for_gosec do + file_type { :sast } + file_format { :raw } + + after(:build) do |artifact, _| + artifact.file = fixture_file_upload( + Rails.root.join('spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json'), 'application/json') + end + end + trait :common_security_report do file_format { :raw } file_type { :dependency_scanning } diff --git a/spec/factories/custom_emoji.rb b/spec/factories/custom_emoji.rb index 88e50eafa7c..09ac4a79a85 100644 --- a/spec/factories/custom_emoji.rb +++ b/spec/factories/custom_emoji.rb @@ -3,9 +3,8 @@ FactoryBot.define do factory :custom_emoji, class: 'CustomEmoji' do sequence(:name) { |n| "custom_emoji#{n}" } - namespace group file { 'https://gitlab.com/images/partyparrot.png' } - creator { namespace.owner } + creator factory: :user end end diff --git a/spec/factories/events.rb b/spec/factories/events.rb index d182dc9f95f..403165a3935 100644 --- a/spec/factories/events.rb +++ b/spec/factories/events.rb @@ -59,6 +59,11 @@ FactoryBot.define do target { design } end + factory :design_updated_event, traits: [:has_design] do + action { :updated } + target { design } + end + factory :project_created_event do project factory: :project action { :created } diff --git a/spec/factories/gitlab/database/background_migration/batched_migrations.rb b/spec/factories/gitlab/database/background_migration/batched_migrations.rb index 79b4447b76e..5ff90ff44b9 100644 --- a/spec/factories/gitlab/database/background_migration/batched_migrations.rb +++ b/spec/factories/gitlab/database/background_migration/batched_migrations.rb @@ -13,12 +13,24 @@ FactoryBot.define do total_tuple_count { 10_000 } pause_ms { 100 } - trait :finished do - status { :finished } + trait(:paused) do + status { 0 } end - trait :failed do - status { :failed } + trait(:active) do + status { 1 } + end + + trait(:finished) do + status { 3 } + end + + trait(:failed) do + status { 4 } + end + + trait(:finalizing) do + status { 5 } end end end diff --git a/spec/factories/go_module_versions.rb b/spec/factories/go_module_versions.rb index 145e6c95921..bdbd5a4423a 100644 --- a/spec/factories/go_module_versions.rb +++ b/spec/factories/go_module_versions.rb @@ -5,12 +5,10 @@ FactoryBot.define do skip_create initialize_with do - p = attributes[:params] - s = Packages::SemVer.parse(p.semver, prefixed: true) + s = Packages::SemVer.parse(semver, prefixed: true) + raise ArgumentError, "invalid sematic version: #{semver.inspect}" if !s && semver - raise ArgumentError, "invalid sematic version: '#{p.semver}'" if !s && p.semver - - new(p.mod, p.type, p.commit, name: p.name, semver: s, ref: p.ref) + new(mod, type, commit, name: name, semver: s, ref: ref) end mod { association(:go_module) } @@ -20,8 +18,6 @@ FactoryBot.define do semver { nil } ref { nil } - params { OpenStruct.new(mod: mod, type: type, commit: commit, name: name, semver: semver, ref: ref) } - trait :tagged do ref { mod.project.repository.find_tag(name) } commit { ref.dereferenced_target } @@ -36,8 +32,8 @@ FactoryBot.define do .max_by(&:to_s) .to_s end - - params { OpenStruct.new(mod: mod, type: :ref, commit: commit, semver: name, ref: ref) } + type { :ref } + semver { name } end end end diff --git a/spec/factories/groups.rb b/spec/factories/groups.rb index aa264ad3377..152ae061605 100644 --- a/spec/factories/groups.rb +++ b/spec/factories/groups.rb @@ -118,14 +118,5 @@ FactoryBot.define do create(:crm_settings, group: group, enabled: true) end end - - trait :test_group do - path { "test-group-fulfillment#{SecureRandom.hex(4)}" } - created_at { 4.days.ago } - - after(:create) do |group| - group.add_owner(create(:user, email: "test-user-#{SecureRandom.hex(4)}@test.com")) - end - end end end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb index 0ffa15ad403..3945637c2c3 100644 --- a/spec/factories/integrations.rb +++ b/spec/factories/integrations.rb @@ -189,7 +189,7 @@ FactoryBot.define do end trait :chat_notification do - webhook { 'https://example.com/webhook' } + sequence(:webhook) { |n| "https://example.com/webhook/#{n}" } end trait :inactive do diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 26c858665a8..8c714f7736f 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -58,6 +58,11 @@ FactoryBot.define do end end + trait :task do + issue_type { :task } + association :work_item_type, :default, :task + end + factory :incident do issue_type { :incident } association :work_item_type, :default, :incident diff --git a/spec/factories/keys.rb b/spec/factories/keys.rb index 2af1c6cc62d..6b800e3d790 100644 --- a/spec/factories/keys.rb +++ b/spec/factories/keys.rb @@ -19,6 +19,12 @@ FactoryBot.define do user end + factory :personal_key_4096 do + user + + key { SSHData::PrivateKey::RSA.generate(4096, unsafe_allow_small_key: true).public_key.openssh(comment: 'dummy@gitlab.com') } + end + factory :another_key do factory :another_deploy_key, class: 'DeployKey' end @@ -74,6 +80,8 @@ FactoryBot.define do qpPN5jAskkAUzOh5L/M+dmq2jNn03U9xwORCYPZj+fFM9bL99/0knsV0ypZDZyWH dummy@gitlab.com KEY end + + factory :rsa_deploy_key_5120, class: 'DeployKey' end factory :rsa_key_8192 do diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index 26804b38db8..e897a5e022a 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -65,11 +65,12 @@ FactoryBot.define do transient do merged_by { author } + merged_at { nil } end after(:build) do |merge_request, evaluator| metrics = merge_request.build_metrics - metrics.merged_at = 1.week.from_now + metrics.merged_at = evaluator.merged_at || 1.week.from_now metrics.merged_by = evaluator.merged_by metrics.pipeline = create(:ci_empty_pipeline) end diff --git a/spec/factories/project_statistics.rb b/spec/factories/project_statistics.rb index ee2ad507c2d..53107879d77 100644 --- a/spec/factories/project_statistics.rb +++ b/spec/factories/project_statistics.rb @@ -24,6 +24,7 @@ FactoryBot.define do 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 + project_statistics.container_registry_size = evaluator.size_multiplier * 9 end end end diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index ef1313541f8..b3395758729 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -59,7 +59,7 @@ FactoryBot.define do builds_access_level = [evaluator.builds_access_level, evaluator.repository_access_level].min merge_requests_access_level = [evaluator.merge_requests_access_level, evaluator.repository_access_level].min - hash = { + project_feature_hash = { wiki_access_level: evaluator.wiki_access_level, builds_access_level: builds_access_level, snippets_access_level: evaluator.snippets_access_level, @@ -75,7 +75,16 @@ FactoryBot.define do security_and_compliance_access_level: evaluator.security_and_compliance_access_level } - project.build_project_feature(hash) + project_namespace_hash = { + name: evaluator.name, + path: evaluator.path, + parent: evaluator.namespace, + shared_runners_enabled: evaluator.shared_runners_enabled, + visibility_level: evaluator.visibility_level + } + + project.build_project_namespace(project_namespace_hash) + project.build_project_feature(project_feature_hash) end after(:create) do |project, evaluator| diff --git a/spec/factories/work_items/work_item_types.rb b/spec/factories/work_items/work_item_types.rb index 0920b36bcbd..1b6137503d3 100644 --- a/spec/factories/work_items/work_item_types.rb +++ b/spec/factories/work_items/work_item_types.rb @@ -37,5 +37,10 @@ FactoryBot.define do base_type { WorkItems::Type.base_types[:requirement] } icon_name { 'issue-type-requirements' } end + + trait :task do + base_type { WorkItems::Type.base_types[:task] } + icon_name { 'issue-type-task' } + end end end diff --git a/spec/fast_spec_helper.rb b/spec/fast_spec_helper.rb index ce3c9af22f1..6cbe97fb3f3 100644 --- a/spec/fast_spec_helper.rb +++ b/spec/fast_spec_helper.rb @@ -1,6 +1,5 @@ # 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 diff --git a/spec/features/admin/admin_broadcast_messages_spec.rb b/spec/features/admin/admin_broadcast_messages_spec.rb index e40f4c4678c..875eb9dd0ce 100644 --- a/spec/features/admin/admin_broadcast_messages_spec.rb +++ b/spec/features/admin/admin_broadcast_messages_spec.rb @@ -22,9 +22,8 @@ RSpec.describe 'Admin Broadcast Messages' do it 'creates a customized broadcast banner message' do fill_in 'broadcast_message_message', with: 'Application update from **4:00 CST to 5:00 CST**' - fill_in 'broadcast_message_color', with: '#f2dede' fill_in 'broadcast_message_target_path', with: '*/user_onboarded' - fill_in 'broadcast_message_font', with: '#b94a48' + select 'light-indigo', from: 'broadcast_message_theme' select Date.today.next_year.year, from: 'broadcast_message_ends_at_1i' check 'Guest' check 'Owner' @@ -35,7 +34,7 @@ RSpec.describe 'Admin Broadcast Messages' do expect(page).to have_content 'Guest, Owner' expect(page).to have_content '*/user_onboarded' expect(page).to have_selector 'strong', text: '4:00 CST to 5:00 CST' - expect(page).to have_selector %(div[style="background-color: #f2dede; color: #b94a48"]) + expect(page).to have_selector %(.light-indigo[role=alert]) end it 'creates a customized broadcast notification message' do @@ -90,7 +89,7 @@ RSpec.describe 'Admin Broadcast Messages' do fill_in 'broadcast_message_message', with: "Live **Markdown** previews. :tada:" select 'Notification', from: 'broadcast_message_broadcast_type' - page.within('.js-broadcast-notification-message-preview') do + page.within('#broadcast-message-preview') do expect(page).to have_selector('strong', text: 'Markdown') expect(page).to have_emoji('tada') end diff --git a/spec/features/admin/admin_dev_ops_report_spec.rb b/spec/features/admin/admin_dev_ops_report_spec.rb deleted file mode 100644 index cee79f8f440..00000000000 --- a/spec/features/admin/admin_dev_ops_report_spec.rb +++ /dev/null @@ -1,68 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'DevOps Report page', :js do - before do - admin = create(:admin) - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - end - - context 'with devops_adoption feature flag disabled' do - before do - stub_feature_flags(devops_adoption: false) - end - - it 'has dismissable intro callout' do - visit admin_dev_ops_report_path - - expect(page).to have_content 'Introducing Your DevOps Report' - - page.within(find('[data-testid="devops-score-container"]')) do - find('[data-testid="close-icon"]').click - end - - expect(page).not_to have_content 'Introducing Your DevOps Report' - end - - context 'when usage ping is disabled' do - before do - stub_application_setting(usage_ping_enabled: false) - end - - it 'shows empty state' do - visit admin_dev_ops_report_path - - expect(page).to have_text('Service ping is off') - end - - it 'hides the intro callout' do - visit admin_dev_ops_report_path - - expect(page).not_to have_content 'Introducing Your DevOps Report' - end - end - - 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 - - expect(page).to have_content('Data is still calculating') - end - end - - context 'when there is data to display' do - it 'shows the DevOps Score app' do - stub_application_setting(usage_ping_enabled: true) - create(:dev_ops_report_metric) - - visit admin_dev_ops_report_path - - expect(page).to have_selector('[data-testid="devops-score-app"]') - end - end - end -end diff --git a/spec/features/admin/admin_dev_ops_reports_spec.rb b/spec/features/admin/admin_dev_ops_reports_spec.rb new file mode 100644 index 00000000000..bf32819cb52 --- /dev/null +++ b/spec/features/admin/admin_dev_ops_reports_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'DevOps Report page', :js do + before do + admin = create(:admin) + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + context 'with devops_adoption feature flag disabled' do + before do + stub_feature_flags(devops_adoption: false) + end + + it 'has dismissable intro callout' do + visit admin_dev_ops_reports_path + + expect(page).to have_content 'Introducing Your DevOps Report' + + page.within(find('[data-testid="devops-score-container"]')) do + find('[data-testid="close-icon"]').click + end + + expect(page).not_to have_content 'Introducing Your DevOps Report' + end + + context 'when usage ping is disabled' do + before do + stub_application_setting(usage_ping_enabled: false) + end + + it 'shows empty state' do + visit admin_dev_ops_reports_path + + expect(page).to have_text('Service ping is off') + end + + it 'hides the intro callout' do + visit admin_dev_ops_reports_path + + expect(page).not_to have_content 'Introducing Your DevOps Report' + end + end + + 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_reports_path + + expect(page).to have_content('Data is still calculating') + end + end + + context 'when there is data to display' do + it 'shows the DevOps Score app' do + stub_application_setting(usage_ping_enabled: true) + create(:dev_ops_report_metric) + + visit admin_dev_ops_reports_path + + expect(page).to have_selector('[data-testid="devops-score-app"]') + end + end + end +end diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 3f0c7e64a1f..7fe49c2571c 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -3,65 +3,71 @@ require 'spec_helper' RSpec.describe "Admin Runners" do - include StubENV - include Spec::Support::Helpers::ModalHelpers + include Spec::Support::Helpers::Features::RunnersHelpers + + let_it_be(:admin) { create(:admin) } before do - stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') - admin = create(:admin) sign_in(admin) gitlab_enable_admin_mode_sign_in(admin) wait_for_requests end - describe "Runners page", :js do + describe "Admin Runners page", :js do let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group) } let_it_be(:namespace) { create(:namespace) } let_it_be(:project) { create(:project, namespace: namespace, creator: user) } - context "when there are runners" do - it 'has all necessary texts' do - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now) - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago) - create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago) - + context "runners registration" do + before do visit admin_runners_path - - expect(page).to have_text "Register an instance runner" - expect(page).to have_text "Online runners 1" - expect(page).to have_text "Offline runners 2" - expect(page).to have_text "Stale runners 1" end - it 'with an instance runner shows an instance badge' do - runner = create(:ci_runner, :instance) + it_behaves_like "shows and resets runner registration token" do + let(:dropdown_text) { 'Register an instance runner' } + let(:registration_token) { Gitlab::CurrentSettings.runners_registration_token } + end + end - visit admin_runners_path + context "when there are runners" do + context "with an instance runner" do + let!(:instance_runner) { create(:ci_runner, :instance) } - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'shared' + before do + visit admin_runners_path end - end - it 'with a group runner shows a group badge' do - runner = create(:ci_runner, :group, groups: [group]) + it_behaves_like 'shows runner in list' do + let(:runner) { instance_runner } + end - visit admin_runners_path + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { instance_runner } + end - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'group' + it 'shows an instance badge' do + within_runner_row(instance_runner.id) do + expect(page).to have_selector '.badge', text: 'shared' + end end end - it 'with a project runner shows a project badge' do - runner = create(:ci_runner, :project, projects: [project]) + context "with multiple runners" do + before do + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.zone.now) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago) + create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago) - visit admin_runners_path + visit admin_runners_path + end - within "[data-testid='runner-row-#{runner.id}']" do - expect(page).to have_selector '.badge', text: 'specific' + it 'has all necessary texts' do + expect(page).to have_text "Register an instance runner" + expect(page).to have_text "Online runners 1" + expect(page).to have_text "Offline runners 2" + expect(page).to have_text "Stale runners 1" end end @@ -73,44 +79,8 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - within "[data-testid='runner-row-#{runner.id}'] [data-label='Jobs']" do - expect(page).to have_content '2' - end - end - - describe 'delete runner' do - let!(:runner) { create(:ci_runner, description: 'runner-foo') } - - before do - visit admin_runners_path - - within "[data-testid='runner-row-#{runner.id}']" do - click_on 'Delete runner' - end - end - - it 'shows a confirmation modal' do - expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?" - expect(page).to have_text "Are you sure you want to continue?" - end - - it 'deletes a runner' do - within '.modal' do - click_on 'Delete runner' - end - - expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/) - expect(page).not_to have_content 'runner-foo' - end - - it 'cancels runner deletion' do - within '.modal' do - click_on 'Cancel' - end - - wait_for_requests - - expect(page).to have_content 'runner-foo' + within_runner_row(runner.id) do + expect(find("[data-label='Jobs']")).to have_content '2' end end @@ -154,35 +124,69 @@ RSpec.describe "Admin Runners" do end end + describe 'filter by paused' do + before do + create(:ci_runner, :instance, description: 'runner-active') + create(:ci_runner, :instance, description: 'runner-paused', active: false) + + visit admin_runners_path + end + + it 'shows all runners' do + expect(page).to have_link('All 2') + + expect(page).to have_content 'runner-active' + expect(page).to have_content 'runner-paused' + end + + it 'shows paused runners' do + input_filtered_search_filter_is_only('Paused', 'Yes') + + expect(page).to have_link('All 1') + + expect(page).not_to have_content 'runner-active' + expect(page).to have_content 'runner-paused' + end + + it 'shows active runners' do + input_filtered_search_filter_is_only('Paused', 'No') + + expect(page).to have_link('All 1') + + expect(page).to have_content 'runner-active' + expect(page).not_to have_content 'runner-paused' + end + end + describe 'filter by status' do let!(:never_contacted) { create(:ci_runner, :instance, description: 'runner-never-contacted', contacted_at: nil) } before do create(:ci_runner, :instance, description: 'runner-1', contacted_at: Time.zone.now) create(:ci_runner, :instance, description: 'runner-2', contacted_at: Time.zone.now) - create(:ci_runner, :instance, description: 'runner-paused', active: false, contacted_at: Time.zone.now) + create(:ci_runner, :instance, description: 'runner-offline', contacted_at: 1.week.ago) visit admin_runners_path end it 'shows all runners' do + expect(page).to have_link('All 4') + expect(page).to have_content 'runner-1' expect(page).to have_content 'runner-2' - expect(page).to have_content 'runner-paused' + expect(page).to have_content 'runner-offline' expect(page).to have_content 'runner-never-contacted' - - expect(page).to have_link('All 4') end it 'shows correct runner when status matches' do - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Status', 'Online') - expect(page).to have_link('All 3') + expect(page).to have_link('All 2') expect(page).to have_content 'runner-1' expect(page).to have_content 'runner-2' - expect(page).to have_content 'runner-never-contacted' - expect(page).not_to have_content 'runner-paused' + expect(page).not_to have_content 'runner-offline' + expect(page).not_to have_content 'runner-never-contacted' end it 'shows no runner when status does not match' do @@ -194,15 +198,15 @@ RSpec.describe "Admin Runners" do end it 'shows correct runner when status is selected and search term is entered' do - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Status', 'Online') input_filtered_search_keys('runner-1') expect(page).to have_link('All 1') expect(page).to have_content 'runner-1' expect(page).not_to have_content 'runner-2' + expect(page).not_to have_content 'runner-offline' expect(page).not_to have_content 'runner-never-contacted' - expect(page).not_to have_content 'runner-paused' end it 'shows correct runner when status filter is entered' do @@ -216,7 +220,7 @@ RSpec.describe "Admin Runners" do expect(page).not_to have_content 'runner-paused' expect(page).to have_content 'runner-never-contacted' - within "[data-testid='runner-row-#{never_contacted.id}']" do + within_runner_row(never_contacted.id) do expect(page).to have_selector '.badge', text: 'never contacted' end end @@ -308,7 +312,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path - input_filtered_search_filter_is_only('Status', 'Active') + input_filtered_search_filter_is_only('Paused', 'No') expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-group' @@ -330,6 +334,17 @@ RSpec.describe "Admin Runners" do create(:ci_runner, :instance, description: 'runner-red', tag_list: ['red']) end + it 'shows tags suggestions' do + visit admin_runners_path + + open_filtered_search_suggestions('Tags') + + page.within(search_bar_selector) do + expect(page).to have_content 'blue' + expect(page).to have_content 'red' + end + end + it 'shows correct runner when tag matches' do visit admin_runners_path @@ -403,15 +418,7 @@ RSpec.describe "Admin Runners" do visit admin_runners_path end - it 'has all necessary texts including no runner message' do - expect(page).to have_text "Register an instance runner" - - expect(page).to have_text "Online runners 0" - expect(page).to have_text "Offline runners 0" - expect(page).to have_text "Stale runners 0" - - expect(page).to have_text 'No runners found' - end + it_behaves_like "shows no runners" it 'shows tabs with total counts equal to 0' do expect(page).to have_link('All 0') @@ -427,65 +434,17 @@ RSpec.describe "Admin Runners" do expect(page).to have_current_path(admin_runners_path('status[]': 'NEVER_CONTACTED') ) end - end - describe 'runners registration' do - let!(:token) { Gitlab::CurrentSettings.runners_registration_token } - - before do - visit admin_runners_path + it 'updates ACTIVE runner status to paused=false' do + visit admin_runners_path('status[]': 'ACTIVE') - click_on 'Register an instance runner' + expect(page).to have_current_path(admin_runners_path('paused[]': 'false') ) end - describe 'show registration instructions' do - before do - click_on 'Show runner installation and registration instructions' - - wait_for_requests - end - - it 'opens runner installation modal' do - expect(page).to have_text "Install a runner" - - expect(page).to have_text "Environment" - expect(page).to have_text "Architecture" - expect(page).to have_text "Download and install binary" - end - - it 'dismisses runner installation modal' do - within_modal do - click_button('Close', match: :first) - end - - expect(page).not_to have_text "Install a runner" - end - end + it 'updates PAUSED runner status to paused=true' do + visit admin_runners_path('status[]': 'PAUSED') - it 'has a registration token' do - click_on 'Click to reveal' - expect(page.find('[data-testid="token-value"]')).to have_content(token) - end - - describe 'reset registration token' do - let(:page_token) { find('[data-testid="token-value"]').text } - - before do - click_on 'Reset registration token' - - within_modal do - click_button('Reset token', match: :first) - end - - wait_for_requests - end - - it 'changes registration token' do - click_on 'Register an instance runner' - - click_on 'Click to reveal' - expect(page_token).not_to eq token - end + expect(page).to have_current_path(admin_runners_path('paused[]': 'true') ) end end end @@ -637,47 +596,4 @@ RSpec.describe "Admin Runners" do end end end - - private - - def search_bar_selector - '[data-testid="runners-filtered-search"]' - end - - # The filters must be clicked first to be able to receive events - # See: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1493 - def focus_filtered_search - page.within(search_bar_selector) do - page.find('.gl-filtered-search-term-token').click - end - end - - def input_filtered_search_keys(search_term) - focus_filtered_search - - page.within(search_bar_selector) do - page.find('input').send_keys(search_term) - click_on 'Search' - end - - wait_for_requests - end - - def input_filtered_search_filter_is_only(filter, value) - focus_filtered_search - - page.within(search_bar_selector) do - click_on filter - - # For OPERATOR_IS_ONLY, clicking the filter - # immediately preselects "=" operator - - page.find('input').send_keys(value) - page.find('input').send_keys(:enter) - - click_on 'Search' - end - - wait_for_requests - end end diff --git a/spec/features/admin/admin_sees_background_migrations_spec.rb b/spec/features/admin/admin_sees_background_migrations_spec.rb index d05a09a79ef..432721d63ad 100644 --- a/spec/features/admin/admin_sees_background_migrations_spec.rb +++ b/spec/features/admin/admin_sees_background_migrations_spec.rb @@ -5,9 +5,9 @@ require 'spec_helper' RSpec.describe "Admin > Admin sees background migrations" do let_it_be(:admin) { create(:admin) } - let_it_be(:active_migration) { create(:batched_background_migration, table_name: 'active', status: :active) } - let_it_be(:failed_migration) { create(:batched_background_migration, table_name: 'failed', status: :failed, total_tuple_count: 100) } - let_it_be(:finished_migration) { create(:batched_background_migration, table_name: 'finished', status: :finished) } + let_it_be(:active_migration) { create(:batched_background_migration, :active, table_name: 'active') } + let_it_be(:failed_migration) { create(:batched_background_migration, :failed, table_name: 'failed', total_tuple_count: 100) } + let_it_be(:finished_migration) { create(:batched_background_migration, :finished, table_name: 'finished') } before_all do create(:batched_background_migration_job, :failed, batched_migration: failed_migration, batch_size: 10, min_value: 6, max_value: 15, attempts: 3) @@ -81,7 +81,7 @@ RSpec.describe "Admin > Admin sees background migrations" do expect(page).to have_content(failed_migration.job_class_name) expect(page).to have_content(failed_migration.table_name) expect(page).to have_content('0.00%') - expect(page).to have_content(failed_migration.status.humanize) + expect(page).to have_content(failed_migration.status_name.to_s) click_button('Retry') expect(page).not_to have_content(failed_migration.job_class_name) @@ -106,7 +106,7 @@ RSpec.describe "Admin > Admin sees background migrations" do expect(page).to have_content(finished_migration.job_class_name) expect(page).to have_content(finished_migration.table_name) expect(page).to have_content('100.00%') - expect(page).to have_content(finished_migration.status.humanize) + expect(page).to have_content(finished_migration.status_name.to_s) end end end diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index df93bd773a6..4cdc3df978d 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -34,16 +34,16 @@ RSpec.describe 'Admin updates settings' do it 'uncheck all restricted visibility levels' do page.within('.as-visibility-access') do - find('#application_setting_visibility_level_0').set(false) - find('#application_setting_visibility_level_10').set(false) - find('#application_setting_visibility_level_20').set(false) + find('#application_setting_restricted_visibility_levels_0').set(false) + find('#application_setting_restricted_visibility_levels_10').set(false) + find('#application_setting_restricted_visibility_levels_20').set(false) click_button 'Save changes' end expect(page).to have_content "Application settings saved successfully" - expect(find('#application_setting_visibility_level_0')).not_to be_checked - expect(find('#application_setting_visibility_level_10')).not_to be_checked - expect(find('#application_setting_visibility_level_20')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_0')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_10')).not_to be_checked + expect(find('#application_setting_restricted_visibility_levels_20')).not_to be_checked end it 'modify import sources' do @@ -311,7 +311,9 @@ RSpec.describe 'Admin updates settings' do end context 'CI/CD page' do - it 'change CI/CD settings' do + let_it_be(:default_plan) { create(:default_plan) } + + it 'changes CI/CD settings' do visit ci_cd_admin_application_settings_path page.within('.as-ci-cd') do @@ -329,6 +331,33 @@ RSpec.describe 'Admin updates settings' do expect(page).to have_content "Application settings saved successfully" end + it 'changes CI/CD limits', :aggregate_failures do + visit ci_cd_admin_application_settings_path + + page.within('.as-ci-cd') do + fill_in 'plan_limits_ci_pipeline_size', with: 10 + fill_in 'plan_limits_ci_active_jobs', with: 20 + fill_in 'plan_limits_ci_active_pipelines', with: 25 + fill_in 'plan_limits_ci_project_subscriptions', with: 30 + fill_in 'plan_limits_ci_pipeline_schedules', with: 40 + fill_in 'plan_limits_ci_needs_size_limit', with: 50 + fill_in 'plan_limits_ci_registered_group_runners', with: 60 + fill_in 'plan_limits_ci_registered_project_runners', with: 70 + click_button 'Save Default limits' + end + + limits = default_plan.reload.limits + expect(limits.ci_pipeline_size).to eq(10) + expect(limits.ci_active_jobs).to eq(20) + expect(limits.ci_active_pipelines).to eq(25) + expect(limits.ci_project_subscriptions).to eq(30) + expect(limits.ci_pipeline_schedules).to eq(40) + expect(limits.ci_needs_size_limit).to eq(50) + expect(limits.ci_registered_group_runners).to eq(60) + expect(limits.ci_registered_project_runners).to eq(70) + expect(page).to have_content 'Application limits saved successfully' + end + context 'Runner Registration' do context 'when feature is enabled' do before do @@ -421,7 +450,7 @@ RSpec.describe 'Admin updates settings' do visit ci_cd_admin_application_settings_path page.within('.as-registry') do - find('#application_setting_container_registry_expiration_policies_caching.form-check-input').click + find('#application_setting_container_registry_expiration_policies_caching').click click_button 'Save changes' end @@ -489,8 +518,8 @@ RSpec.describe 'Admin updates settings' do page.within('.as-spam') do fill_in 'reCAPTCHA site key', with: 'key' fill_in 'reCAPTCHA private key', with: 'key' - check 'Enable reCAPTCHA' - check 'Enable reCAPTCHA for login' + find('#application_setting_recaptcha_enabled').set(true) + find('#application_setting_login_recaptcha_protection_enabled').set(true) fill_in 'IP addresses per user', with: 15 check 'Enable Spam Check via external API endpoint' fill_in 'URL of the external Spam Check endpoint', with: 'grpc://www.example.com/spamcheck' @@ -825,31 +854,45 @@ RSpec.describe 'Admin updates settings' do before do stub_usage_data_connections stub_database_flavor_check - - visit service_usage_data_admin_application_settings_path end - it 'loads usage ping payload on click', :js do - expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m + context 'when service data cached', :clean_gitlab_redis_cache do + before do + allow(Rails.cache).to receive(:exist?).with('usage_data').and_return(true) - expect(page).not_to have_content expected_payload_content + visit service_usage_data_admin_application_settings_path + end - click_button('Preview payload') + it 'loads usage ping payload on click', :js do + expected_payload_content = /(?=.*"uuid")(?=.*"hostname")/m - wait_for_requests + expect(page).not_to have_content expected_payload_content - expect(page).to have_button 'Hide payload' - expect(page).to have_content expected_payload_content - end + click_button('Preview payload') - it 'generates usage ping payload on button click', :js do - expect_next_instance_of(Admin::ApplicationSettingsController) do |instance| - expect(instance).to receive(:usage_data).and_call_original + wait_for_requests + + expect(page).to have_button 'Hide payload' + expect(page).to have_content expected_payload_content + end + + it 'generates usage ping payload on button click', :js do + expect_next_instance_of(Admin::ApplicationSettingsController) do |instance| + expect(instance).to receive(:usage_data).and_call_original + end + + click_button('Download payload') + + wait_for_requests end + end - click_button('Download payload') + context 'when service data not cached' do + it 'renders missing cache information' do + visit service_usage_data_admin_application_settings_path - wait_for_requests + expect(page).to have_text('Service Ping payload not found in the application cache') + end end end end diff --git a/spec/features/admin/admin_users_impersonation_tokens_spec.rb b/spec/features/admin/admin_users_impersonation_tokens_spec.rb index 6643ebe82e6..15bc2318022 100644 --- a/spec/features/admin/admin_users_impersonation_tokens_spec.rb +++ b/spec/features/admin/admin_users_impersonation_tokens_spec.rb @@ -36,14 +36,14 @@ RSpec.describe 'Admin > Users > Impersonation Tokens', :js do click_on "1" # Scopes - check "api" + check "read_api" check "read_user" click_on "Create impersonation token" expect(active_impersonation_tokens).to have_text(name) expect(active_impersonation_tokens).to have_text('in') - expect(active_impersonation_tokens).to have_text('api') + expect(active_impersonation_tokens).to have_text('read_api') expect(active_impersonation_tokens).to have_text('read_user') expect(PersonalAccessTokensFinder.new(impersonation: true).execute.count).to equal(1) expect(created_impersonation_token).not_to be_empty diff --git a/spec/features/admin/clusters/eks_spec.rb b/spec/features/admin/clusters/eks_spec.rb index 71d2bba73b1..4667f9c20a1 100644 --- a/spec/features/admin/clusters/eks_spec.rb +++ b/spec/features/admin/clusters/eks_spec.rb @@ -15,8 +15,8 @@ RSpec.describe 'Instance-level AWS EKS Cluster', :js do before do visit admin_clusters_path - click_button 'Actions' - click_link 'Create a new cluster' + click_button(class: 'dropdown-toggle-split') + click_link 'Create a cluster (deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 5dd627f3b76..bf976168bbe 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -282,7 +282,7 @@ RSpec.describe 'Project issue boards', :js do it 'shows issue count on the list' do page.within(find(".board:nth-child(2)")) do expect(page.find('[data-testid="board-items-count"]')).to have_text(total_planning_issues) - expect(page).not_to have_selector('.js-max-issue-size') + expect(page).not_to have_selector('.max-issue-size') end end end diff --git a/spec/features/boards/focus_mode_spec.rb b/spec/features/boards/focus_mode_spec.rb index 2bd1e625236..453a8d8870b 100644 --- a/spec/features/boards/focus_mode_spec.rb +++ b/spec/features/boards/focus_mode_spec.rb @@ -12,6 +12,6 @@ RSpec.describe 'Issue Boards focus mode', :js do end it 'shows focus mode button to anonymous users' do - expect(page).to have_selector('.js-focus-mode-btn') + expect(page).to have_button _('Toggle focus mode') end end diff --git a/spec/features/boards/multi_select_spec.rb b/spec/features/boards/multi_select_spec.rb index 9148fb23214..cad303a14e5 100644 --- a/spec/features/boards/multi_select_spec.rb +++ b/spec/features/boards/multi_select_spec.rb @@ -72,7 +72,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[2]) do + page.within(all('.board-list')[2]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) end @@ -87,7 +87,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[2]) do + page.within(all('.board-list')[2]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) expect(find('.board-card:nth-child(3)')).to have_content(issue3.title) @@ -102,7 +102,7 @@ RSpec.describe 'Multi Select Issue', :js do wait_for_requests - page.within(all('.js-board-list')[1]) do + page.within(all('.board-list')[1]) do expect(find('.board-card:nth-child(1)')).to have_content(issue1.title) expect(find('.board-card:nth-child(2)')).to have_content(issue2.title) expect(find('.board-card:nth-child(3)')).to have_content(issue5.title) diff --git a/spec/features/clusters/create_agent_spec.rb b/spec/features/clusters/create_agent_spec.rb index e03126d344e..c7326204bf6 100644 --- a/spec/features/clusters/create_agent_spec.rb +++ b/spec/features/clusters/create_agent_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'Cluster agent registration', :js do end it 'allows the user to select an agent to install, and displays the resulting agent token' do - click_button('Actions') + click_button('Connect a cluster') expect(page).to have_content('Register') click_button('Select an agent') @@ -34,7 +34,7 @@ RSpec.describe 'Cluster agent registration', :js do expect(page).to have_content('You cannot see this token again after you close this window.') expect(page).to have_content('example-agent-token') - expect(page).to have_content('docker run --pull=always --rm') + expect(page).to have_content('helm upgrade --install') within find('.modal-footer') do click_button('Close') diff --git a/spec/features/commit_spec.rb b/spec/features/commit_spec.rb index 3fd613ce393..c9fa10d58e6 100644 --- a/spec/features/commit_spec.rb +++ b/spec/features/commit_spec.rb @@ -33,6 +33,10 @@ RSpec.describe 'Commit' do it "reports the correct number of total changes" do expect(page).to have_content("Changes #{commit.diffs.size}") end + + it 'renders diff stats', :js do + expect(page).to have_selector(".diff-stats") + end end describe "pagination" do diff --git a/spec/features/commits_spec.rb b/spec/features/commits_spec.rb index db841ffc627..4b38df175e2 100644 --- a/spec/features/commits_spec.rb +++ b/spec/features/commits_spec.rb @@ -10,6 +10,7 @@ RSpec.describe 'Commits' do before do sign_in(user) stub_ci_pipeline_to_return_yaml_file + stub_feature_flags(pipeline_tabs_vue: false) end let(:creator) { create(:user, developer_projects: [project]) } @@ -93,6 +94,7 @@ RSpec.describe 'Commits' do context 'Download artifacts', :js do before do + stub_feature_flags(pipeline_tabs_vue: false) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) end @@ -122,6 +124,7 @@ RSpec.describe 'Commits' do context "when logged as reporter", :js do before do + stub_feature_flags(pipeline_tabs_vue: false) project.add_reporter(user) create(:ci_job_artifact, :archive, file: artifacts_file, job: build) visit builds_project_pipeline_path(project, pipeline) diff --git a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb index 89bf79ebb81..40718deed75 100644 --- a/spec/features/error_tracking/user_searches_sentry_errors_spec.rb +++ b/spec/features/error_tracking/user_searches_sentry_errors_spec.rb @@ -30,7 +30,7 @@ RSpec.describe 'When a user searches for Sentry errors', :js, :use_clean_rails_m expect(results.count).to be(3) end - find('.gl-form-input').set('NotFound').native.send_keys(:return) + find('.filtered-search-input-container .gl-form-input').set('NotFound').native.send_keys(:return) page.within(find('.gl-table')) do results = page.all('.table-row') diff --git a/spec/features/groups/clusters/eks_spec.rb b/spec/features/groups/clusters/eks_spec.rb index 3cca2d0919c..0e64a2faf3e 100644 --- a/spec/features/groups/clusters/eks_spec.rb +++ b/spec/features/groups/clusters/eks_spec.rb @@ -20,8 +20,8 @@ RSpec.describe 'Group AWS EKS Cluster', :js do before do visit group_clusters_path(group) - click_button 'Actions' - click_link 'Create a new cluster' + click_button(class: 'dropdown-toggle-split') + click_link 'Create a cluster (deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/groups/clusters/user_spec.rb b/spec/features/groups/clusters/user_spec.rb index 2ed6ddc09ab..74ea72b238f 100644 --- a/spec/features/groups/clusters/user_spec.rb +++ b/spec/features/groups/clusters/user_spec.rb @@ -25,7 +25,7 @@ RSpec.describe 'User Cluster', :js do before do visit group_clusters_path(group) - click_link 'Connect with a certificate' + click_link 'Connect a cluster (deprecated)' end context 'when user filled form with valid parameters' do @@ -119,7 +119,6 @@ RSpec.describe 'User Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end diff --git a/spec/features/groups/group_runners_spec.rb b/spec/features/groups/group_runners_spec.rb new file mode 100644 index 00000000000..1d821edefa3 --- /dev/null +++ b/spec/features/groups/group_runners_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe "Group Runners" do + include Spec::Support::Helpers::Features::RunnersHelpers + + let_it_be(:group_owner) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + before do + group.add_owner(group_owner) + sign_in(group_owner) + end + + describe "Group runners page", :js do + let!(:group_registration_token) { group.runners_token } + + context "runners registration" do + before do + visit group_runners_path(group) + end + + it_behaves_like "shows and resets runner registration token" do + let(:dropdown_text) { 'Register a group runner' } + let(:registration_token) { group_registration_token } + end + end + + context "with no runners" do + before do + visit group_runners_path(group) + end + + it_behaves_like "shows no runners" + + it 'shows tabs with total counts equal to 0' do + expect(page).to have_link('All 0') + expect(page).to have_link('Group 0') + expect(page).to have_link('Project 0') + end + end + + context "with an online group runner" do + let!(:group_runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end + + before do + visit group_runners_path(group) + end + + it_behaves_like 'shows runner in list' do + let(:runner) { group_runner } + end + + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { group_runner } + end + + it 'shows a group badge' do + within_runner_row(group_runner.id) do + expect(page).to have_selector '.badge', text: 'group' + end + end + + it 'can edit runner information' do + within_runner_row(group_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, group_runner)) + end + end + end + + context "with an online project runner" do + let!(:project_runner) do + create(:ci_runner, :project, projects: [project], description: 'runner-bar', contacted_at: Time.zone.now) + end + + before do + visit group_runners_path(group) + end + + it_behaves_like 'shows runner in list' do + let(:runner) { project_runner } + end + + it_behaves_like 'pauses, resumes and deletes a runner' do + let(:runner) { project_runner } + end + + it 'shows a project (specific) badge' do + within_runner_row(project_runner.id) do + expect(page).to have_selector '.badge', text: 'specific' + end + end + + it 'can edit runner information' do + within_runner_row(project_runner.id) do + expect(find_link('Edit')[:href]).to end_with(edit_group_runner_path(group, project_runner)) + end + end + end + + context 'with a multi-project runner' do + let(:project) { create(:project, group: group) } + let(:project_2) { create(:project, group: group) } + let!(:runner) { create(:ci_runner, :project, projects: [project, project_2], description: 'group-runner') } + + it 'user cannot remove the project runner' do + visit group_runners_path(group) + + within_runner_row(runner.id) do + expect(page).to have_button 'Delete runner', disabled: true + end + end + end + + context 'filtered search' do + before do + visit group_runners_path(group) + end + + it 'allows user to search by paused and status', :js do + focus_filtered_search + + page.within(search_bar_selector) do + expect(page).to have_link('Paused') + expect(page).to have_content('Status') + end + end + end + end + + describe "Group runner edit page", :js do + let!(:runner) do + create(:ci_runner, :group, groups: [group], description: 'runner-foo', contacted_at: Time.zone.now) + end + + it 'user edits the runner to be protected' do + visit edit_group_runner_path(group, runner) + + expect(page.find_field('runner[access_level]')).not_to be_checked + + check 'runner_access_level' + click_button 'Save changes' + + expect(page).to have_content 'Protected Yes' + end + + context 'when a runner has a tag' do + before do + runner.update!(tag_list: ['tag']) + end + + it 'user edits runner not to run untagged jobs' do + visit edit_group_runner_path(group, runner) + + expect(page.find_field('runner[run_untagged]')).to be_checked + + uncheck 'runner_run_untagged' + click_button 'Save changes' + + expect(page).to have_content 'Can run untagged jobs No' + end + end + end +end diff --git a/spec/features/groups/import_export/export_file_spec.rb b/spec/features/groups/import_export/export_file_spec.rb index 9feb8085e66..e3cb1ad77a7 100644 --- a/spec/features/groups/import_export/export_file_spec.rb +++ b/spec/features/groups/import_export/export_file_spec.rb @@ -26,22 +26,6 @@ RSpec.describe 'Group Export', :js do end end - context 'when the group import/export FF is disabled' do - before do - stub_feature_flags(group_import_export: false) - - group.add_owner(user) - sign_in(user) - end - - it 'does not show the group export options' do - visit edit_group_path(group) - - expect(page).to have_content('Advanced') - expect(page).not_to have_content('Export group') - end - end - context 'when the signed in user does not have the required permission level' do before do group.add_guest(user) diff --git a/spec/features/groups/members/manage_groups_spec.rb b/spec/features/groups/members/manage_groups_spec.rb index 5ab5a7ea716..5a9223d9ee8 100644 --- a/spec/features/groups/members/manage_groups_spec.rb +++ b/spec/features/groups/members/manage_groups_spec.rb @@ -3,7 +3,6 @@ require 'spec_helper' RSpec.describe 'Groups > Members > Manage groups', :js do - include Select2Helper include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::Features::InviteMembersModalHelper include Spec::Support::Helpers::ModalHelpers @@ -119,16 +118,92 @@ RSpec.describe 'Groups > Members > Manage groups', :js do describe 'group search results' do let_it_be(:group, refind: true) { create(:group) } - let_it_be(:group_within_hierarchy) { create(:group, parent: group) } - let_it_be(:group_outside_hierarchy) { create(:group) } - before_all do - group.add_owner(user) - group_within_hierarchy.add_owner(user) - group_outside_hierarchy.add_owner(user) + context 'with instance admin considerations' do + let_it_be(:group_to_share) { create(:group) } + + context 'when user is an admin' do + let_it_be(:admin) { create(:admin) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + it 'shows groups where the admin has no direct membership' do + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + + it 'shows groups where the admin has at least guest level membership' do + group_to_share.add_guest(admin) + + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + end + + context 'when user is not an admin' do + before do + group.add_owner(user) + end + + it 'shows groups where the user has no direct membership' do + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_not_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + + it 'shows groups where the user has at least guest level membership' do + group_to_share.add_guest(user) + + visit group_group_members_path(group) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + expect_not_to_have_group(group) + end + end + end end - context 'when the invite members group modal is enabled' do + context 'when user is not an admin and there are hierarchy considerations' do + let_it_be(:group_within_hierarchy) { create(:group, parent: group) } + let_it_be(:group_outside_hierarchy) { create(:group) } + + before_all do + group.add_owner(user) + group_within_hierarchy.add_owner(user) + group_outside_hierarchy.add_owner(user) + end + it 'does not show self or ancestors', :aggregate_failures do group_sibbling = create(:group, parent: group) group_sibbling.add_owner(user) @@ -139,46 +214,46 @@ RSpec.describe 'Groups > Members > Manage groups', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do - expect(page).to have_selector("[entity-id='#{group_outside_hierarchy.id}']") - expect(page).to have_selector("[entity-id='#{group_sibbling.id}']") - expect(page).not_to have_selector("[entity-id='#{group.id}']") - expect(page).not_to have_selector("[entity-id='#{group_within_hierarchy.id}']") + page.within(group_dropdown_selector) do + expect_to_have_group(group_outside_hierarchy) + expect_to_have_group(group_sibbling) + expect_not_to_have_group(group) + expect_not_to_have_group(group_within_hierarchy) end end - end - context 'when sharing with groups outside the hierarchy is enabled' do - it 'shows groups within and outside the hierarchy in search results' do - visit group_group_members_path(group) + context 'when sharing with groups outside the hierarchy is enabled' do + it 'shows groups within and outside the hierarchy in search results' do + visit group_group_members_path(group) - click_on 'Invite a group' - click_on 'Select a group' + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests - expect(page).to have_text group_within_hierarchy.name - expect(page).to have_text group_outside_hierarchy.name + page.within(group_dropdown_selector) do + expect_to_have_group(group_within_hierarchy) + expect_to_have_group(group_outside_hierarchy) + end + end end - end - context 'when sharing with groups outside the hierarchy is disabled' do - before do - group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true) - end + context 'when sharing with groups outside the hierarchy is disabled' do + before do + group.namespace_settings.update!(prevent_sharing_groups_outside_hierarchy: true) + end - it 'shows only groups within the hierarchy in search results' do - visit group_group_members_path(group) + it 'shows only groups within the hierarchy in search results' do + visit group_group_members_path(group) - click_on 'Invite a group' - click_on 'Select a group' + click_on 'Invite a group' + click_on 'Select a group' - expect(page).to have_text group_within_hierarchy.name - expect(page).not_to have_text group_outside_hierarchy.name + page.within(group_dropdown_selector) do + expect_to_have_group(group_within_hierarchy) + expect_not_to_have_group(group_outside_hierarchy) + end + end end end 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 533d2118b30..468001c3be6 100644 --- a/spec/features/groups/members/manage_members_spec.rb +++ b/spec/features/groups/members/manage_members_spec.rb @@ -42,46 +42,6 @@ RSpec.describe 'Groups > Members > Manage members' do end end - it 'add user to group', :js, :snowplow, :aggregate_failures do - group.add_owner(user1) - - visit group_group_members_path(group) - - invite_member(user2.name, role: 'Reporter') - - page.within(second_row) do - expect(page).to have_content(user2.name) - expect(page).to have_button('Reporter') - end - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: 'group-members-page', - property: 'existing_user', - user: user1 - ) - end - - it 'do not disclose email addresses', :js do - group.add_owner(user1) - create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe") - - visit group_group_members_path(group) - - click_on 'Invite members' - find('[data-testid="members-token-select-input"]').set('@gitlab.com') - - wait_for_requests - - expect(page).to have_content('No matches found') - - find('[data-testid="members-token-select-input"]').set('undisclosed_email@gitlab.com') - wait_for_requests - - expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email') - end - it 'remove user from group', :js do group.add_owner(user1) group.add_developer(user2) @@ -106,43 +66,29 @@ RSpec.describe 'Groups > Members > Manage members' do end end - it 'add yourself to group when already an owner', :js, :aggregate_failures do - group.add_owner(user1) - - visit group_group_members_path(group) - - invite_member(user1.name, role: 'Reporter') - - page.within(first_row) do - expect(page).to have_content(user1.name) - expect(page).to have_content('Owner') - end - end + context 'when inviting' do + it 'add yourself to group when already an owner', :js do + group.add_owner(user1) - it 'invite user to group', :js, :snowplow do - group.add_owner(user1) + visit group_group_members_path(group) - visit group_group_members_path(group) + invite_member(user1.name, role: 'Reporter', refresh: false) - invite_member('test@example.com', role: 'Reporter') + expect(page).to have_selector(invite_modal_selector) + expect(page).to have_content("not authorized to update member") - expect(page).to have_link 'Invited' - click_link 'Invited' + page.refresh - aggregate_failures 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') + page.within find_member_row(user1) do + expect(page).to have_content('Owner') end + end - expect_snowplow_event( - category: 'Members::InviteService', - action: 'create_member', - label: 'group-members-page', - property: 'net_new_user', - user: user1 - ) + it_behaves_like 'inviting members', 'group-members-page' do + let_it_be(:entity) { group } + let_it_be(:members_page_path) { group_group_members_path(entity) } + let_it_be(:subentity) { create(:group, parent: group) } + let_it_be(:subentity_members_page_path) { group_group_members_path(subentity) } end end @@ -169,4 +115,57 @@ RSpec.describe 'Groups > Members > Manage members' do end end end + + describe 'member search results', :js do + before do + group.add_owner(user1) + end + + it 'does not disclose email addresses' do + create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe") + + visit group_group_members_path(group) + + click_on 'Invite members' + find(member_dropdown_selector).set('@gitlab.com') + + wait_for_requests + + expect(page).to have_content('No matches found') + + find(member_dropdown_selector).set('undisclosed_email@gitlab.com') + wait_for_requests + + expect(page).to have_content('Invite "undisclosed_email@gitlab.com" by email') + end + + it 'does not show project_bots', :aggregate_failures do + internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_') + project = create(:project, group: group) + project.add_maintainer(internal_project_bot) + + external_group = create(:group) + external_project_bot = create(:user, :project_bot, name: '_external_project_bot_') + external_project = create(:project, group: external_group) + external_project.add_maintainer(external_project_bot) + external_project.add_maintainer(user1) + + visit group_group_members_path(group) + + click_on 'Invite members' + + page.within invite_modal_selector do + field = find(member_dropdown_selector) + field.native.send_keys :tab + field.click + + wait_for_requests + + expect(page).to have_content(user1.name) + expect(page).to have_content(user2.name) + expect(page).not_to have_content(internal_project_bot.name) + expect(page).not_to have_content(external_project_bot.name) + end + end + end end diff --git a/spec/features/groups/members/sort_members_spec.rb b/spec/features/groups/members/sort_members_spec.rb index 03758e0d401..bf8e64fa1e2 100644 --- a/spec/features/groups/members/sort_members_spec.rb +++ b/spec/features/groups/members/sort_members_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' 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(:owner) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) } + let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) } let(:group) { create(:group) } before do @@ -50,6 +50,42 @@ RSpec.describe 'Groups > Members > Sort members', :js do expect_sort_by('Max role', :desc) end + it 'sorts by user created on ascending' do + visit_members_list(sort: :oldest_created_user) + + expect(first_row.text).to include(owner.name) + expect(second_row.text).to include(developer.name) + + expect_sort_by('Created on', :asc) + end + + it 'sorts by user created on descending' do + visit_members_list(sort: :recent_created_user) + + expect(first_row.text).to include(developer.name) + expect(second_row.text).to include(owner.name) + + expect_sort_by('Created on', :desc) + end + + it 'sorts by last activity ascending' do + visit_members_list(sort: :oldest_last_activity) + + expect(first_row.text).to include(developer.name) + expect(second_row.text).to include(owner.name) + + expect_sort_by('Last activity', :asc) + end + + it 'sorts by last activity descending' do + visit_members_list(sort: :recent_last_activity) + + expect(first_row.text).to include(owner.name) + expect(second_row.text).to include(developer.name) + + expect_sort_by('Last activity', :desc) + end + it 'sorts by access granted ascending' do visit_members_list(sort: :last_joined) diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 4edf27e8fa4..42eaa8358a1 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -66,7 +66,7 @@ RSpec.describe 'Group milestones' do context 'when no milestones' do it 'renders no milestones text' do visit group_milestones_path(group) - expect(page).to have_content('No milestones to show') + expect(page).to have_content('Use milestones to track issues and merge requests') end end diff --git a/spec/features/groups/milestones_sorting_spec.rb b/spec/features/groups/milestones_sorting_spec.rb index a06e64fdee0..22d7ff91d41 100644 --- a/spec/features/groups/milestones_sorting_spec.rb +++ b/spec/features/groups/milestones_sorting_spec.rb @@ -17,7 +17,7 @@ RSpec.describe 'Milestones sorting', :js do sign_in(user) end - it 'visit group milestones and sort by due_date_asc' do + it 'visit group milestones and sort by due_date_asc', :js do visit group_milestones_path(group) expect(page).to have_button('Due soon') @@ -27,13 +27,13 @@ RSpec.describe 'Milestones sorting', :js do expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(['v2.0', 'v2.0', 'v3.0', 'v1.0', 'v1.0']) end - click_button 'Due soon' + within '[data-testid=milestone_sort_by_dropdown]' do + click_button 'Due soon' + expect(find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) - expect(find('ul.dropdown-menu-sort li').all('a').map(&:text)).to eq(['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending']) - - click_link 'Due later' - - expect(page).to have_button('Due later') + click_button 'Due later' + expect(page).to have_button('Due later') + end # assert descending sorting within '.milestones' do diff --git a/spec/features/groups/settings/ci_cd_spec.rb b/spec/features/groups/settings/ci_cd_spec.rb index 8851aeb6381..c5ad524e647 100644 --- a/spec/features/groups/settings/ci_cd_spec.rb +++ b/spec/features/groups/settings/ci_cd_spec.rb @@ -5,52 +5,73 @@ require 'spec_helper' RSpec.describe 'Group CI/CD settings' do include WaitForRequests - let(:user) { create(:user) } - let(:group) { create(:group) } + let_it_be(:user) { create(:user) } + let_it_be(:group, reload: true) { create(:group) } - before do + before_all do group.add_owner(user) + end + + before do sign_in(user) end - describe 'new group runners view banner' do - it 'displays banner' do - visit group_settings_ci_cd_path(group) + describe 'Runners section' do + let(:shared_runners_toggle) { page.find('[data-testid="enable-runners-toggle"]') } + + context 'with runner_list_group_view_vue_ui enabled' do + before do + visit group_settings_ci_cd_path(group) + end + + it 'displays the new group runners view banner' do + expect(page).to have_content(s_('Runners|New group runners view')) + expect(page).to have_link(href: group_runners_path(group)) + end - expect(page).to have_content(s_('Runners|New group runners view')) - expect(page).to have_link(href: group_runners_path(group)) + it 'has "Enable shared runners for this group" toggle', :js do + expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group')) + end end - it 'does not display banner' do - stub_feature_flags(runner_list_group_view_vue_ui: false) + context 'with runner_list_group_view_vue_ui disabled' do + before do + stub_feature_flags(runner_list_group_view_vue_ui: false) - visit group_settings_ci_cd_path(group) + visit group_settings_ci_cd_path(group) + end - expect(page).not_to have_content(s_('Runners|New group runners view')) - expect(page).not_to have_link(href: group_runners_path(group)) - end - end + it 'does not display the new group runners view banner' do + expect(page).not_to have_content(s_('Runners|New group runners view')) + expect(page).not_to have_link(href: group_runners_path(group)) + end - describe 'runners registration token' do - let!(:token) { group.runners_token } + it 'has "Enable shared runners for this group" toggle', :js do + expect(shared_runners_toggle).to have_content(_('Enable shared runners for this group')) + end - before do - visit group_settings_ci_cd_path(group) - end + context 'with runners registration token' do + let!(:token) { group.runners_token } - it 'has a registration token' do - expect(page.find('#registration_token')).to have_content(token) - end + before do + visit group_settings_ci_cd_path(group) + end - describe 'reload registration token' do - let(:page_token) { find('#registration_token').text } + it 'displays the registration token' do + expect(page.find('#registration_token')).to have_content(token) + end - before do - click_button 'Reset registration token' - end + describe 'reload registration token' do + let(:page_token) { find('#registration_token').text } + + before do + click_button 'Reset registration token' + end - it 'changes registration token' do - expect(page_token).not_to eq token + it 'changes the registration token' do + expect(page_token).not_to eq token + end + end end end end diff --git a/spec/features/issuables/shortcuts_issuable_spec.rb b/spec/features/issuables/shortcuts_issuable_spec.rb index 7e8f39c47a7..528420062dd 100644 --- a/spec/features/issuables/shortcuts_issuable_spec.rb +++ b/spec/features/issuables/shortcuts_issuable_spec.rb @@ -15,12 +15,20 @@ RSpec.describe 'Blob shortcuts', :js do end shared_examples "quotes the selected text" do - it "quotes the selected text", :quarantine do - select_element('.note-text') + it 'quotes the selected text in main comment form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do + select_element('#notes-list .note:first-child .note-text') find('body').native.send_key('r') expect(find('.js-main-target-form .js-vue-comment-form').value).to include(note_text) end + + it 'quotes the selected text in the discussion reply form', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/356388' do + find('#notes-list .note:first-child .js-reply-button').click + select_element('#notes-list .note:first-child .note-text') + find('body').native.send_key('r') + + expect(find('#notes-list .note:first-child .js-vue-markdown-field .js-gfm-input').value).to include(note_text) + end end describe 'pressing "r"' do diff --git a/spec/features/issues/incident_issue_spec.rb b/spec/features/issues/incident_issue_spec.rb index 2956ddede2e..a2519a44604 100644 --- a/spec/features/issues/incident_issue_spec.rb +++ b/spec/features/issues/incident_issue_spec.rb @@ -56,5 +56,37 @@ RSpec.describe 'Incident Detail', :js do end end end + + context 'when on summary tab' do + before do + click_link 'Summary' + end + + it 'shows the summary tab with all components' do + page.within('.issuable-details') do + hidden_items = find_all('.js-issue-widgets') + + # Linked Issues/MRs and comment box + expect(hidden_items.count).to eq(2) + + expect(hidden_items).to all(be_visible) + end + end + end + + context 'when on alert details tab' do + before do + click_link 'Alert details' + end + + it 'does not show the linked issues and notes/comment components' do + page.within('.issuable-details') do + hidden_items = find_all('.js-issue-widgets') + + # Linked Issues/MRs and comment box are hidden on page + expect(hidden_items.count).to eq(0) + end + end + end end end diff --git a/spec/features/issues/user_creates_issue_spec.rb b/spec/features/issues/user_creates_issue_spec.rb index 446f13dc4d0..8a5e33ba18c 100644 --- a/spec/features/issues/user_creates_issue_spec.rb +++ b/spec/features/issues/user_creates_issue_spec.rb @@ -71,6 +71,12 @@ RSpec.describe "User creates issue" do expect(preview).to have_css("gl-emoji") expect(textarea).not_to be_visible + + click_button("Write") + fill_in("Description", with: "/confidential") + click_button("Preview") + + expect(form).to have_content('Makes this issue confidential.') end end end diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb index 8c906e6a27c..3b440002cb5 100644 --- a/spec/features/issues/user_edits_issue_spec.rb +++ b/spec/features/issues/user_edits_issue_spec.rb @@ -35,6 +35,12 @@ RSpec.describe "Issues > User edits issue", :js do end expect(form).to have_button("Write") + + click_button("Write") + fill_in("Description", with: "/confidential") + click_button("Preview") + + expect(form).to have_content('Makes this issue confidential.') end it 'allows user to select unassigned' do diff --git a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb index 6473fe01052..311818d2d15 100644 --- a/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb +++ b/spec/features/issues/user_sees_sidebar_updates_in_realtime_spec.rb @@ -6,6 +6,10 @@ RSpec.describe 'Issues > Real-time sidebar', :js do let_it_be(:project) { create(:project, :public) } let_it_be(:issue) { create(:issue, project: project) } let_it_be(:user) { create(:user) } + let_it_be(:label) { create(:label, project: project, name: 'Development') } + + let(:labels_widget) { find('[data-testid="sidebar-labels"]') } + let(:labels_value) { find('[data-testid="value-wrapper"]') } before_all do project.add_developer(user) @@ -32,4 +36,37 @@ RSpec.describe 'Issues > Real-time sidebar', :js do expect(page.find('.assignee')).to have_content user.name end end + + it 'updates the label in real-time' do + Capybara::Session.new(:other_session) + + using_session :other_session do + visit project_issue_path(project, issue) + wait_for_requests + expect(labels_value).to have_content('None') + end + + sign_in(user) + + visit project_issue_path(project, issue) + wait_for_requests + expect(labels_value).to have_content('None') + + page.within(labels_widget) do + click_on 'Edit' + end + + wait_for_all_requests + + click_button label.name + click_button 'Close' + + wait_for_requests + + expect(labels_value).to have_content(label.name) + + using_session :other_session do + expect(labels_value).to have_content(label.name) + end + end end diff --git a/spec/features/jira_connect/subscriptions_spec.rb b/spec/features/jira_connect/subscriptions_spec.rb index 0b7321bf271..94c293c88b9 100644 --- a/spec/features/jira_connect/subscriptions_spec.rb +++ b/spec/features/jira_connect/subscriptions_spec.rb @@ -36,7 +36,7 @@ RSpec.describe 'Subscriptions Content Security Policy' do it 'appends to CSP directives' do visit jira_connect_subscriptions_path(jwt: jwt) - is_expected.to include("frame-ancestors 'self' https://*.atlassian.net") + is_expected.to include("frame-ancestors 'self' https://*.atlassian.net https://*.jira.com") is_expected.to include("script-src 'self' https://some-cdn.test https://connect-cdn.atl-paas.net") is_expected.to include("style-src 'self' https://some-cdn.test 'unsafe-inline'") end diff --git a/spec/features/jira_oauth_provider_authorize_spec.rb b/spec/features/jira_oauth_provider_authorize_spec.rb index daecae56101..a216d2d44b2 100644 --- a/spec/features/jira_oauth_provider_authorize_spec.rb +++ b/spec/features/jira_oauth_provider_authorize_spec.rb @@ -4,13 +4,13 @@ require 'spec_helper' RSpec.describe 'JIRA OAuth Provider' do describe 'JIRA DVCS OAuth Authorization' do - let(:application) { create(:oauth_application, redirect_uri: oauth_jira_callback_url, scopes: 'read_user') } + let(:application) { create(:oauth_application, redirect_uri: oauth_jira_dvcs_callback_url, scopes: 'read_user') } before do sign_in(user) - visit oauth_jira_authorize_path(client_id: application.uid, - redirect_uri: oauth_jira_callback_url, + visit oauth_jira_dvcs_authorize_path(client_id: application.uid, + redirect_uri: oauth_jira_dvcs_callback_url, response_type: 'code', state: 'my_state', scope: 'read_user') diff --git a/spec/features/merge_request/user_merges_merge_request_spec.rb b/spec/features/merge_request/user_merges_merge_request_spec.rb index d1be93cae02..a861ca2eea5 100644 --- a/spec/features/merge_request/user_merges_merge_request_spec.rb +++ b/spec/features/merge_request/user_merges_merge_request_spec.rb @@ -10,7 +10,7 @@ RSpec.describe "User merges a merge request", :js do end shared_examples "fast forward merge a merge request" do - it "merges a merge request", :sidekiq_might_not_need_inline do + it "merges a merge request", :sidekiq_inline do expect(page).to have_content("Fast-forward merge without a merge commit").and have_button("Merge") page.within(".mr-state-widget") do @@ -42,4 +42,23 @@ RSpec.describe "User merges a merge request", :js do it_behaves_like "fast forward merge a merge request" end end + + context 'sidebar merge requests counter' do + let(:project) { create(:project, :public, :repository) } + let!(:merge_request) { create(:merge_request, source_project: project) } + + it 'decrements the open MR count', :sidekiq_inline do + create(:merge_request, source_project: project, source_branch: 'branch-1') + + visit(merge_request_path(merge_request)) + + expect(page).to have_css('.js-merge-counter', text: '2') + + page.within(".mr-state-widget") do + click_button("Merge") + end + + expect(page).to have_css('.js-merge-counter', text: '1') + end + end end diff --git a/spec/features/merge_request/user_posts_notes_spec.rb b/spec/features/merge_request/user_posts_notes_spec.rb index 1779567624c..ad602afe68a 100644 --- a/spec/features/merge_request/user_posts_notes_spec.rb +++ b/spec/features/merge_request/user_posts_notes_spec.rb @@ -169,7 +169,7 @@ RSpec.describe 'Merge request > User posts notes', :js do end page.within('.modal') do - click_button('OK', match: :first) + click_button('Cancel editing', match: :first) end expect(find('.js-note-text').text).to eq '' 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 27f7c699c50..c9b21d4a4ae 100644 --- a/spec/features/merge_request/user_sees_merge_widget_spec.rb +++ b/spec/features/merge_request/user_sees_merge_widget_spec.rb @@ -17,6 +17,9 @@ RSpec.describe 'Merge request > User sees merge widget', :js do project.add_maintainer(user) project_only_mwps.add_maintainer(user) sign_in(user) + + stub_feature_flags(refactor_mr_widgets_extensions: false) + stub_feature_flags(refactor_mr_widgets_extensions_user: false) end context 'new merge request', :sidekiq_might_not_need_inline do 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 beb658bb7a0..f77a42ee506 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 @@ -379,4 +379,41 @@ RSpec.describe 'User comments on a diff', :js do end end end + + context 'failed to load metadata' do + let(:dummy_controller) do + Class.new(Projects::MergeRequests::DiffsController) do + def diffs_metadata + render json: '', status: :internal_server_error + end + end + end + + before do + stub_const('Projects::MergeRequests::DiffsController', dummy_controller) + + click_diff_line(find_by_scrolling("[id='#{sample_compare.changes[1][:line_code]}']")) + + page.within('.js-discussion-note-form') do + fill_in('note_note', with: "```suggestion\n# change to a comment\n```") + click_button('Add comment now') + end + + wait_for_requests + + visit(project_merge_request_path(project, merge_request)) + + wait_for_requests + end + + it 'displays an error' do + page.within('.discussion-notes') do + click_button('Apply suggestion') + + wait_for_requests + + expect(page).to have_content('Unable to fully load the default commit message. You can still apply this suggestion and the commit message will be correct.') + end + end + end end diff --git a/spec/features/merge_requests/user_mass_updates_spec.rb b/spec/features/merge_requests/user_mass_updates_spec.rb index f781ba0827c..a15b6072e70 100644 --- a/spec/features/merge_requests/user_mass_updates_spec.rb +++ b/spec/features/merge_requests/user_mass_updates_spec.rb @@ -70,7 +70,7 @@ RSpec.describe 'Merge requests > User mass updates', :js do it 'updates merge request with assignee' do change_assignee(user.name) - expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}, go to their profile." + expect(find('.issuable-meta a.author-link')[:title]).to eq "Attention requested from assignee #{user.name}" end end end diff --git a/spec/features/milestones/user_deletes_milestone_spec.rb b/spec/features/milestones/user_deletes_milestone_spec.rb index ede9faed876..40626407642 100644 --- a/spec/features/milestones/user_deletes_milestone_spec.rb +++ b/spec/features/milestones/user_deletes_milestone_spec.rb @@ -21,7 +21,7 @@ RSpec.describe "User deletes milestone", :js do click_button("Delete") click_button("Delete milestone") - expect(page).to have_content("No milestones to show") + expect(page).to have_content("Use milestones to track issues and merge requests over a fixed period of time") visit(activity_project_path(project)) diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb index 93674057fed..ea5bb8c33b2 100644 --- a/spec/features/oauth_login_spec.rb +++ b/spec/features/oauth_login_spec.rb @@ -16,7 +16,7 @@ RSpec.describe 'OAuth Login', :js, :allow_forgery_protection do end providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2, - :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk] + :facebook, :cas3, :auth0, :authentiq, :salesforce, :dingtalk, :alicloud] around do |example| with_omniauth_full_host { example.run } diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index f1e5658cd7b..8cbc0491441 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -47,14 +47,14 @@ RSpec.describe 'Profile > Personal Access Tokens', :js do click_on "1" # Scopes - check "api" + check "read_api" check "read_user" click_on "Create personal access token" expect(active_personal_access_tokens).to have_text(name) expect(active_personal_access_tokens).to have_text('in') - expect(active_personal_access_tokens).to have_text('api') + expect(active_personal_access_tokens).to have_text('read_api') expect(active_personal_access_tokens).to have_text('read_user') expect(created_personal_access_token).not_to be_empty end diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 026da5814e3..4b6ed458c68 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -8,8 +8,6 @@ RSpec.describe 'User edit profile' do let(:user) { create(:user) } before do - stub_feature_flags(improved_emoji_picker: false) - sign_in(user) visit(profile_path) end @@ -169,10 +167,9 @@ RSpec.describe 'User edit profile' do context 'user status', :js do def select_emoji(emoji_name, is_modal = false) - emoji_menu_class = is_modal ? '.js-modal-status-emoji-menu' : '.js-status-emoji-menu' - toggle_button = find('.js-toggle-emoji-menu') + toggle_button = find('.emoji-menu-toggle-button') toggle_button.click - emoji_button = find(%Q{#{emoji_menu_class} .js-emoji-btn gl-emoji[data-name="#{emoji_name}"]}) + emoji_button = find("gl-emoji[data-name=\"#{emoji_name}\"]") emoji_button.click end @@ -207,7 +204,7 @@ RSpec.describe 'User edit profile' do end it 'adds message and emoji to user status' do - emoji = 'tanabata_tree' + emoji = '8ball' message = 'Playing outside' select_emoji(emoji) fill_in 'js-status-message-field', with: message @@ -356,7 +353,7 @@ RSpec.describe 'User edit profile' do end it 'adds emoji to user status' do - emoji = 'biohazard' + emoji = '8ball' open_user_status_modal select_emoji(emoji, true) set_user_status_in_modal @@ -387,18 +384,18 @@ RSpec.describe 'User edit profile' do it 'opens the emoji modal again after closing it' do open_user_status_modal - select_emoji('biohazard', true) + select_emoji('8ball', true) - find('.js-toggle-emoji-menu').click + find('.emoji-menu-toggle-button').click - expect(page).to have_selector('.emoji-menu') + expect(page).to have_selector('.emoji-picker-emoji') end it 'does not update the awards panel emoji' do project.add_maintainer(user) visit(project_issue_path(project, issue)) - emoji = 'biohazard' + emoji = '8ball' open_user_status_modal select_emoji(emoji, true) @@ -420,7 +417,7 @@ RSpec.describe 'User edit profile' do end it 'adds message and emoji to user status' do - emoji = 'tanabata_tree' + emoji = '8ball' message = 'Playing outside' open_user_status_modal select_emoji(emoji, true) @@ -495,9 +492,7 @@ RSpec.describe 'User edit profile' do open_user_status_modal find('.js-status-message-field').native.send_keys(message) - within('.js-toggle-emoji-menu') do - expect(page).to have_emoji('speech_balloon') - end + expect(page).to have_emoji('speech_balloon') end context 'note header' do diff --git a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb index e19e29bf63a..4c61e8d45e4 100644 --- a/spec/features/profiles/user_visits_profile_preferences_page_spec.rb +++ b/spec/features/profiles/user_visits_profile_preferences_page_spec.rb @@ -18,14 +18,6 @@ RSpec.describe 'User visits the profile preferences page', :js do end describe 'User changes their syntax highlighting theme', :js do - it 'creates a flash message' do - choose 'user_color_scheme_id_5' - - wait_for_requests - - expect_preferences_saved_message - end - it 'updates their preference' do choose 'user_color_scheme_id_5' diff --git a/spec/features/projects/blobs/balsamiq_spec.rb b/spec/features/projects/blobs/balsamiq_spec.rb deleted file mode 100644 index bce60856544..00000000000 --- a/spec/features/projects/blobs/balsamiq_spec.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Balsamiq file blob', :js do - let(:project) { create(:project, :public, :repository) } - - before do - visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr') - - wait_for_requests - end - - it 'displays Balsamiq file content' do - expect(page).to have_content("Mobile examples") - end -end diff --git a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb index 11e2d24c36a..9b0edcd09d2 100644 --- a/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb +++ b/spec/features/projects/blobs/blob_line_permalink_updater_spec.rb @@ -39,7 +39,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do @@ -50,7 +50,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "LC5"))) + expect(find('.js-data-file-blob-permalink-url')['href']).to eq(get_absolute_url(project_blob_path(project, tree_join(sha, path), anchor: "L5"))) end end @@ -75,7 +75,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5"))) end it 'with initial fragment hash, changes fragment hash if line number clicked' do @@ -86,7 +86,7 @@ RSpec.describe 'Blob button line permalinks (BlobLinePermalinkUpdater)', :js do find('#L3').click find("#L5").click - expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "LC5"))) + expect(find('.js-blob-blame-link')['href']).to eq(get_absolute_url(project_blame_path(project, tree_join('master', path), anchor: "L5"))) end end end diff --git a/spec/features/projects/branches_spec.rb b/spec/features/projects/branches_spec.rb index 363d08da024..d906bb396be 100644 --- a/spec/features/projects/branches_spec.rb +++ b/spec/features/projects/branches_spec.rb @@ -36,6 +36,8 @@ RSpec.describe 'Branches' do expect(page).to have_content(sorted_branches(repository, count: 5, sort_by: :updated_desc, state: 'active')) expect(page).to have_content(sorted_branches(repository, count: 4, sort_by: :updated_asc, state: 'stale')) + expect(page).to have_button('Copy branch name') + expect(page).to have_link('Show more active branches', href: project_branches_filtered_path(project, state: 'active')) expect(page).not_to have_content('Show more stale branches') end @@ -197,14 +199,6 @@ RSpec.describe 'Branches' do project.add_maintainer(user) end - describe 'Initial branches page' do - it 'shows description for admin' do - visit project_branches_filtered_path(project, state: 'all') - - expect(page).to have_content("Protected branches can be managed in project settings") - end - end - it 'shows the merge request button' do visit project_branches_path(project) diff --git a/spec/features/projects/cluster_agents_spec.rb b/spec/features/projects/cluster_agents_spec.rb index e9162359940..5d931afe4a7 100644 --- a/spec/features/projects/cluster_agents_spec.rb +++ b/spec/features/projects/cluster_agents_spec.rb @@ -27,7 +27,6 @@ RSpec.describe 'ClusterAgents', :js do end it 'displays empty state', :aggregate_failures do - expect(page).to have_content('Install a new agent') expect(page).to have_selector('.empty-state') end end diff --git a/spec/features/projects/clusters/eks_spec.rb b/spec/features/projects/clusters/eks_spec.rb index 0dd6effe551..7e599ff1198 100644 --- a/spec/features/projects/clusters/eks_spec.rb +++ b/spec/features/projects/clusters/eks_spec.rb @@ -20,7 +20,7 @@ RSpec.describe 'AWS EKS Cluster', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end context 'when user creates a cluster on AWS EKS' do diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 90d7e2d02e9..491121a3743 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -135,7 +135,7 @@ RSpec.describe 'Gcp Cluster', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Connect with a certificate' + click_link 'Connect a cluster (certificate - deprecated)' end it 'user sees the "Environment scope" field' do @@ -154,7 +154,6 @@ RSpec.describe 'Gcp Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end @@ -220,6 +219,6 @@ RSpec.describe 'Gcp Cluster', :js do def visit_create_cluster_page click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end end diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index 3fd78d338da..b6bfaa3a9b9 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -25,8 +25,8 @@ RSpec.describe 'User Cluster', :js do before do visit project_clusters_path(project) - click_link 'Certificate' - click_link 'Connect with a certificate' + click_button(class: 'dropdown-toggle-split') + click_link 'Connect a cluster (certificate - deprecated)' end context 'when user filled form with valid parameters' do @@ -108,7 +108,6 @@ RSpec.describe 'User Cluster', :js do it 'user sees creation form with the successful message' do expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Connect with a certificate') end end end diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index b9a544144c3..0ecd7795964 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -20,7 +20,6 @@ RSpec.describe 'Clusters', :js do end it 'sees empty state' do - expect(page).to have_link('Connect with a certificate') expect(page).to have_selector('.empty-state') end end @@ -222,11 +221,11 @@ RSpec.describe 'Clusters', :js do visit project_clusters_path(project) click_button(class: 'dropdown-toggle-split') - click_link 'Create a new cluster' + click_link 'Create a cluster (certificate - deprecated)' end def visit_connect_cluster_page click_button(class: 'dropdown-toggle-split') - click_link 'Connect with a certificate' + click_link 'Connect a cluster (certificate - deprecated)' end end diff --git a/spec/features/projects/commits/multi_view_diff_spec.rb b/spec/features/projects/commits/multi_view_diff_spec.rb index ecdd398c739..009dd05c6d1 100644 --- a/spec/features/projects/commits/multi_view_diff_spec.rb +++ b/spec/features/projects/commits/multi_view_diff_spec.rb @@ -27,17 +27,11 @@ RSpec.describe 'Multiple view Diffs', :js do context 'when :rendered_diffs_viewer is off' do context 'and diff does not have ipynb' do - include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' + it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' end context 'and diff has ipynb' do - include_examples "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee' - - it 'shows the transformed diff' do - diff = page.find('.diff-file, .file-holder', match: :first) - - expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') - end + it_behaves_like "no multiple viewers", '5d6ed1503801ca9dc28e95eeb85a7cf863527aee' end end @@ -45,14 +39,28 @@ RSpec.describe 'Multiple view Diffs', :js do let(:feature_flag_on) { true } context 'and diff does not include ipynb' do - include_examples "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' - end + it_behaves_like "no multiple viewers", 'ddd0f15ae83993f5cb66a927a28673882e99100b' - context 'and opening a diff with ipynb' do - context 'but the changes are not renderable' do - include_examples "no multiple viewers", 'a867a602d2220e5891b310c07d174fbe12122830' + context 'and in inline diff' do + let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'does not change display for non-ipynb' do + expect(page).to have_selector line_with_content('new', 1) + end end + context 'and in parallel diff' do + let(:ref) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + + it 'does not change display for non-ipynb' do + page.find('#parallel-diff-btn').click + + expect(page).to have_selector line_with_content('new', 1) + end + end + end + + context 'and opening a diff with ipynb' do it 'loads the rendered diff as hidden' do diff = page.find('.diff-file, .file-holder', match: :first) @@ -76,10 +84,55 @@ RSpec.describe 'Multiple view Diffs', :js do expect(classes_for_element(diff, 'toHideBtn')).not_to include('selected') expect(classes_for_element(diff, 'toShowBtn')).to include('selected') end + + it 'transforms the diff' do + diff = page.find('.diff-file, .file-holder', match: :first) + + expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') + end + + context 'on parallel view' do + before do + page.find('#parallel-diff-btn').click + end + + it 'lines without mapping cannot receive comments' do + expect(page).not_to have_selector('td.line_content.nomappinginraw ~ td.diff-line-num > .add-diff-note') + expect(page).to have_selector('td.line_content:not(.nomappinginraw) ~ td.diff-line-num > .add-diff-note') + end + + it 'lines numbers without mapping are empty' do + expect(page).not_to have_selector('td.nomappinginraw + td.diff-line-num') + expect(page).to have_selector('td.nomappinginraw + td.diff-line-num', visible: false) + end + + it 'transforms the diff' do + diff = page.find('.diff-file, .file-holder', match: :first) + + expect(diff['innerHTML']).to include('%% Cell type:markdown id:0aac5da7-745c-4eda-847a-3d0d07a1bb9b tags:') + end + end + + context 'on inline view' do + it 'lines without mapping cannot receive comments' do + expect(page).not_to have_selector('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num > .add-diff-note') + expect(page).to have_selector('tr.line_holder:not([class$="nomappinginraw"]) > td.diff-line-num > .add-diff-note') + end + + it 'lines numbers without mapping are empty' do + elements = page.all('tr.line_holder[class$="nomappinginraw"] > td.diff-line-num').map { |e| e.text(:all) } + + expect(elements).to all(be == "") + end + end end end def classes_for_element(node, data_diff_entity, visible: true) node.find("[data-diff-toggle-entity=\"#{data_diff_entity}\"]", visible: visible)[:class] end + + def line_with_content(old_or_new, line_number) + "td.#{old_or_new}_line.diff-line-num[data-linenumber=\"#{line_number}\"] > a[data-linenumber=\"#{line_number}\"]" + end end diff --git a/spec/features/projects/environments/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 99137018d6b..6cf59394af7 100644 --- a/spec/features/projects/environments/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb @@ -70,7 +70,7 @@ RSpec.describe 'Environments page', :js do it 'shows no environments' do visit_environments(project, scope: 'stopped') - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any stopped environments.')) end end @@ -99,7 +99,7 @@ RSpec.describe 'Environments page', :js do it 'shows no environments' do visit_environments(project, scope: 'available') - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any environments.')) end end @@ -120,7 +120,7 @@ RSpec.describe 'Environments page', :js do end it 'does not show environments and counters are set to zero' do - expect(page).to have_content('You don\'t have any environments right now') + expect(page).to have_content(s_('Environments|You don\'t have any environments.')) expect(page).to have_link("#{_('Available')} 0") expect(page).to have_link("#{_('Stopped')} 0") diff --git a/spec/features/projects/import_export/import_file_spec.rb b/spec/features/projects/import_export/import_file_spec.rb index 1e5c5d33ad9..c7fbaa85483 100644 --- a/spec/features/projects/import_export/import_file_spec.rb +++ b/spec/features/projects/import_export/import_file_spec.rb @@ -24,9 +24,9 @@ RSpec.describe 'Import/Export - project import integration test', :js do context 'when selecting the namespace' do let(:user) { create(:admin) } let!(:namespace) { user.namespace } - let(:randomHex) { SecureRandom.hex } - let(:project_name) { 'Test Project Name' + randomHex } - let(:project_path) { 'test-project-name' + randomHex } + let(:random_hex) { SecureRandom.hex } + let(:project_name) { 'Test Project Name' + random_hex } + let(:project_path) { 'test-project-name' + random_hex } it 'user imports an exported project successfully', :sidekiq_might_not_need_inline do visit new_project_path 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 762f9c33510..48ae70d3ec9 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 @@ -10,8 +10,8 @@ RSpec.describe 'User uploads new design', :js do let(:issue) { create(:issue, project: project) } before do - # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/347334 - stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 102) + # Cause of raising query limiting threshold https://gitlab.com/gitlab-org/gitlab/-/issues/358845 + stub_const("Gitlab::QueryLimiting::Transaction::THRESHOLD", 106) sign_in(user) enable_design_management(feature_enabled) diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index fde6240d373..3b70d177fce 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -67,19 +67,8 @@ RSpec.describe 'User browses jobs' do expect(page.find('[data-testid="jobs-all-tab"] .badge').text).to include('0') end - it 'shows a tab for Pending jobs and count' do - expect(page.find('[data-testid="jobs-pending-tab"]').text).to include('Pending') - expect(page.find('[data-testid="jobs-pending-tab"] .badge').text).to include('0') - end - - it 'shows a tab for Running jobs and count' do - expect(page.find('[data-testid="jobs-running-tab"]').text).to include('Running') - expect(page.find('[data-testid="jobs-running-tab"] .badge').text).to include('0') - end - it 'shows a tab for Finished jobs and count' do expect(page.find('[data-testid="jobs-finished-tab"]').text).to include('Finished') - expect(page.find('[data-testid="jobs-finished-tab"] .badge').text).to include('0') end it 'updates the content when tab is clicked' do diff --git a/spec/features/projects/members/groups_with_access_list_spec.rb b/spec/features/projects/members/groups_with_access_list_spec.rb index 6adc3503492..9bd6476f836 100644 --- a/spec/features/projects/members/groups_with_access_list_spec.rb +++ b/spec/features/projects/members/groups_with_access_list_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Groups with access list', :js do include Spec::Support::Helpers::Features::MembersHelpers include Spec::Support::Helpers::ModalHelpers + include Spec::Support::Helpers::Features::InviteMembersModalHelper let_it_be(:user) { create(:user) } let_it_be(:group) { create(:group, :public) } @@ -95,8 +96,4 @@ RSpec.describe 'Projects > Members > Groups with access list', :js do expect(members_table).to have_content(group.full_name) end end - - def click_groups_tab - click_link 'Groups' - end end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb index 9c256504934..a48229249e0 100644 --- a/spec/features/projects/members/invite_group_spec.rb +++ b/spec/features/projects/members/invite_group_spec.rb @@ -17,20 +17,18 @@ RSpec.describe 'Project > Members > Invite group', :js do visit project_project_members_path(project) - expect(page).to have_selector('button[data-test-id="invite-group-button"]') + expect(page).to have_selector(invite_group_selector) end - it 'does not display the button when visiting the page not signed in' do + it 'does not display the button when visiting the page not signed in' do project = create(:project, namespace: create(:group)) visit project_project_members_path(project) - expect(page).not_to have_selector('button[data-test-id="invite-group-button"]') + expect(page).not_to have_selector(invite_group_selector) end describe 'Share with group lock' do - let(:invite_group_selector) { 'button[data-test-id="invite-group-button"]' } - shared_examples 'the project can be shared with groups' do it 'the "Invite a group" button exists' do visit project_project_members_path(project) @@ -158,21 +156,95 @@ RSpec.describe 'Project > Members > Invite group', :js do describe 'the groups dropdown' do let_it_be(:parent_group) { create(:group, :public) } let_it_be(:project_group) { create(:group, :public, parent: parent_group) } - let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) } - let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) } - let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) } - let_it_be(:private_membership_group) { create(:group, :private) } - let_it_be(:public_membership_group) { create(:group, :public) } let_it_be(:project) { create(:project, group: project_group) } - before do - private_membership_group.add_guest(maintainer) - public_membership_group.add_maintainer(maintainer) + context 'with instance admin considerations' do + let_it_be(:group_to_share) { create(:group) } - sign_in(maintainer) + context 'when user is an admin' do + let_it_be(:admin) { create(:admin) } + + before do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + end + + it 'shows groups where the admin has no direct membership' do + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + + it 'shows groups where the admin has at least guest level membership' do + group_to_share.add_guest(admin) + + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + end + + context 'when user is not an admin' do + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + end + + it 'does not show groups where the user has no direct membership' do + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_not_to_have_group(group_to_share) + end + end + + it 'shows groups where the user has at least guest level membership' do + group_to_share.add_guest(maintainer) + + visit project_project_members_path(project) + + click_on 'Invite a group' + click_on 'Select a group' + wait_for_requests + + page.within(group_dropdown_selector) do + expect_to_have_group(group_to_share) + end + end + end end context 'for a project in a nested group' do + let_it_be(:public_sub_subgroup) { create(:group, :public, parent: project_group) } + let_it_be(:public_sibbling_group) { create(:group, :public, parent: parent_group) } + let_it_be(:private_sibbling_group) { create(:group, :private, parent: parent_group) } + let_it_be(:private_membership_group) { create(:group, :private) } + let_it_be(:public_membership_group) { create(:group, :public) } + let_it_be(:project) { create(:project, group: project_group) } + + before do + private_membership_group.add_guest(maintainer) + public_membership_group.add_maintainer(maintainer) + + sign_in(maintainer) + end + it 'does not show the groups inherited from projects' do project.add_maintainer(maintainer) public_sibbling_group.add_maintainer(maintainer) @@ -183,7 +255,7 @@ RSpec.describe 'Project > Members > Invite group', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do + page.within(group_dropdown_selector) do expect_to_have_group(public_membership_group) expect_to_have_group(public_sibbling_group) expect_to_have_group(private_membership_group) @@ -204,7 +276,7 @@ RSpec.describe 'Project > Members > Invite group', :js do click_on 'Select a group' wait_for_requests - page.within('[data-testid="group-select-dropdown"]') do + page.within(group_dropdown_selector) do expect_to_have_group(public_membership_group) expect_to_have_group(public_sibbling_group) expect_to_have_group(private_membership_group) @@ -215,14 +287,10 @@ RSpec.describe 'Project > Members > Invite group', :js do expect_not_to_have_group(project_group) end end - - def expect_to_have_group(group) - expect(page).to have_selector("[entity-id='#{group.id}']") - end - - def expect_not_to_have_group(group) - expect(page).not_to have_selector("[entity-id='#{group.id}']") - end end end + + def invite_group_selector + 'button[data-test-id="invite-group-button"]' + end end diff --git a/spec/features/projects/members/list_spec.rb b/spec/features/projects/members/list_spec.rb deleted file mode 100644 index f2424a4acc3..00000000000 --- a/spec/features/projects/members/list_spec.rb +++ /dev/null @@ -1,206 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Project members list', :js do - include Spec::Support::Helpers::Features::MembersHelpers - include Spec::Support::Helpers::Features::InviteMembersModalHelper - include Spec::Support::Helpers::ModalHelpers - - let_it_be(:user1) { create(:user, name: 'John Doe') } - let_it_be(:user2) { create(:user, name: 'Mary Jane') } - let_it_be(:group) { create(:group) } - let_it_be(:project) { create(:project, :internal, namespace: group) } - - before do - sign_in(user1) - group.add_owner(user1) - end - - it 'show members from project and group', :aggregate_failures do - project.add_developer(user2) - - visit_members_page - - expect(first_row).to have_content(user1.name) - expect(second_row).to have_content(user2.name) - end - - it 'show user once if member of both group and project', :aggregate_failures do - project.add_developer(user1) - - visit_members_page - - expect(first_row).to have_content(user1.name) - expect(second_row).to be_blank - end - - it 'update user access level' do - project.add_developer(user2) - - visit_members_page - - page.within find_member_row(user2) do - click_button('Developer') - click_button('Reporter') - - expect(page).to have_button('Reporter') - end - end - - it 'add user to project', :snowplow, :aggregate_failures do - visit_members_page - - invite_member(user2.name, role: 'Reporter') - - page.within find_member_row(user2) do - expect(page).to have_button('Reporter') - end - - expect_snowplow_event( - category: 'Members::CreateService', - action: 'create_member', - label: 'project-members-page', - property: 'existing_user', - user: user1 - ) - end - - it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do - visit_members_page - - click_on 'Invite members' - - click_on 'Guest' - wait_for_requests - - page.within '.dropdown-menu' do - expect(page).to have_button('Guest') - expect(page).to have_button('Reporter') - expect(page).to have_button('Developer') - expect(page).to have_button('Maintainer') - expect(page).not_to have_button('Owner') - end - end - - it 'remove user from project' do - other_user = create(:user) - project.add_developer(other_user) - - visit_members_page - - # Open modal - page.within find_member_row(other_user) do - click_button 'Remove member' - end - - within_modal 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 - - expect(members_table).not_to have_content(other_user.name) - end - - it 'invite user to project', :snowplow, :aggregate_failures do - visit_members_page - - invite_member('test@example.com', role: 'Reporter') - - click_link 'Invited' - - page.within find_invited_member_row('test@example.com') do - expect(page).to have_button('Reporter') - end - - expect_snowplow_event( - category: 'Members::InviteService', - action: 'create_member', - label: 'project-members-page', - property: 'net_new_user', - user: user1 - ) - end - - context 'as a signed out visitor viewing a public project' do - let_it_be(:project) { create(:project, :public) } - - before do - sign_out(user1) - end - - it 'does not show the Invite members button when not signed in' do - visit_members_page - - expect(page).not_to have_button('Invite members') - end - end - - context 'project bots' do - let(:project_bot) { create(:user, :project_bot, name: 'project_bot') } - - before do - project.add_maintainer(project_bot) - end - - it 'does not show form used to change roles and "Expiration date" or the remove user button', :aggregate_failures do - visit_members_page - - page.within find_username_row(project_bot) do - expect(page).not_to have_button('Maintainer') - expect(page).to have_field('Expiration date', disabled: true) - expect(page).not_to have_button('Remove member') - end - end - end - - describe 'when user has 2FA enabled' do - let_it_be(:admin) { create(:admin) } - let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) } - - before do - project.add_guest(user_with_2fa) - end - - it 'shows 2FA badge to user with "Maintainer" access level' do - project.add_maintainer(user1) - - visit_members_page - - expect(find_member_row(user_with_2fa)).to have_content('2FA') - end - - it 'shows 2FA badge to admins' do - sign_in(admin) - gitlab_enable_admin_mode_sign_in(admin) - - visit_members_page - - expect(find_member_row(user_with_2fa)).to have_content('2FA') - end - - it 'does not show 2FA badge to users with access level below "Maintainer"' do - group.add_developer(user1) - - visit_members_page - - expect(find_member_row(user_with_2fa)).not_to have_content('2FA') - end - - it 'shows 2FA badge to themselves' do - sign_in(user_with_2fa) - - visit_members_page - - expect(find_member_row(user_with_2fa)).to have_content('2FA') - end - end - - private - - def visit_members_page - visit project_project_members_path(project) - end -end diff --git a/spec/features/projects/members/manage_members_spec.rb b/spec/features/projects/members/manage_members_spec.rb new file mode 100644 index 00000000000..0f4120e88e0 --- /dev/null +++ b/spec/features/projects/members/manage_members_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Projects > Members > Manage members', :js do + include Spec::Support::Helpers::Features::MembersHelpers + include Spec::Support::Helpers::Features::InviteMembersModalHelper + include Spec::Support::Helpers::ModalHelpers + + let_it_be(:user1) { create(:user, name: 'John Doe') } + let_it_be(:user2) { create(:user, name: 'Mary Jane') } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :internal, namespace: group) } + + before do + sign_in(user1) + group.add_owner(user1) + end + + it 'show members from project and group', :aggregate_failures do + project.add_developer(user2) + + visit_members_page + + expect(first_row).to have_content(user1.name) + expect(second_row).to have_content(user2.name) + end + + it 'show user once if member of both group and project', :aggregate_failures do + project.add_developer(user1) + + visit_members_page + + expect(first_row).to have_content(user1.name) + expect(second_row).to be_blank + end + + it 'update user access level' do + project.add_developer(user2) + + visit_members_page + + page.within find_member_row(user2) do + click_button('Developer') + click_button('Reporter') + + expect(page).to have_button('Reporter') + end + end + + it 'uses ProjectMember access_level_roles for the invite members modal access option', :aggregate_failures do + visit_members_page + + click_on 'Invite members' + + click_on 'Guest' + wait_for_requests + + page.within '.dropdown-menu' do + expect(page).to have_button('Guest') + expect(page).to have_button('Reporter') + expect(page).to have_button('Developer') + expect(page).to have_button('Maintainer') + expect(page).not_to have_button('Owner') + end + end + + it 'remove user from project' do + other_user = create(:user) + project.add_developer(other_user) + + visit_members_page + + # Open modal + page.within find_member_row(other_user) do + click_button 'Remove member' + end + + within_modal 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 + + expect(members_table).not_to have_content(other_user.name) + end + + it_behaves_like 'inviting members', 'project-members-page' do + let_it_be(:entity) { project } + let_it_be(:members_page_path) { project_project_members_path(entity) } + let_it_be(:subentity) { project } + let_it_be(:subentity_members_page_path) { project_project_members_path(entity) } + end + + describe 'member search results' do + it 'does not show project_bots', :aggregate_failures do + internal_project_bot = create(:user, :project_bot, name: '_internal_project_bot_') + project.add_maintainer(internal_project_bot) + + external_group = create(:group) + external_project_bot = create(:user, :project_bot, name: '_external_project_bot_') + external_project = create(:project, group: external_group) + external_project.add_maintainer(external_project_bot) + external_project.add_maintainer(user1) + + visit_members_page + + click_on 'Invite members' + + page.within invite_modal_selector do + field = find(member_dropdown_selector) + field.native.send_keys :tab + field.click + + wait_for_requests + + expect(page).to have_content(user1.name) + expect(page).to have_content(user2.name) + expect(page).not_to have_content(internal_project_bot.name) + expect(page).not_to have_content(external_project_bot.name) + end + end + end + + context 'as a signed out visitor viewing a public project' do + let_it_be(:project) { create(:project, :public) } + + before do + sign_out(user1) + end + + it 'does not show the Invite members button when not signed in' do + visit_members_page + + expect(page).not_to have_button('Invite members') + end + end + + context 'project bots' do + let(:project_bot) { create(:user, :project_bot, name: 'project_bot') } + + before do + project.add_maintainer(project_bot) + end + + it 'does not show form used to change roles and "Expiration date" or the remove user button', :aggregate_failures do + visit_members_page + + page.within find_username_row(project_bot) do + expect(page).not_to have_button('Maintainer') + expect(page).to have_field('Expiration date', disabled: true) + expect(page).not_to have_button('Remove member') + end + end + end + + describe 'when user has 2FA enabled' do + let_it_be(:admin) { create(:admin) } + let_it_be(:user_with_2fa) { create(:user, :two_factor_via_otp) } + + before do + project.add_guest(user_with_2fa) + end + + it 'shows 2FA badge to user with "Maintainer" access level' do + project.add_maintainer(user1) + + visit_members_page + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + + it 'shows 2FA badge to admins' do + sign_in(admin) + gitlab_enable_admin_mode_sign_in(admin) + + visit_members_page + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + + it 'does not show 2FA badge to users with access level below "Maintainer"' do + group.add_developer(user1) + + visit_members_page + + expect(find_member_row(user_with_2fa)).not_to have_content('2FA') + end + + it 'shows 2FA badge to themselves' do + sign_in(user_with_2fa) + + visit_members_page + + expect(find_member_row(user_with_2fa)).to have_content('2FA') + end + end + + private + + def visit_members_page + visit project_project_members_path(project) + end +end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 653564d1566..8aadd6302d0 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -5,8 +5,8 @@ require 'spec_helper' RSpec.describe 'Projects > Members > Sorting', :js do include Spec::Support::Helpers::Features::MembersHelpers - let(:maintainer) { create(:user, name: 'John Doe') } - let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } + let(:maintainer) { create(:user, name: 'John Doe', created_at: 5.days.ago, last_activity_on: Date.today) } + let(:developer) { create(:user, name: 'Mary Jane', created_at: 1.day.ago, last_sign_in_at: 5.days.ago, last_activity_on: Date.today - 5) } let(:project) { create(:project, namespace: maintainer.namespace, creator: maintainer) } before do @@ -42,6 +42,42 @@ RSpec.describe 'Projects > Members > Sorting', :js do expect_sort_by('Max role', :desc) end + it 'sorts by user created on ascending' do + visit_members_list(sort: :oldest_created_user) + + expect(first_row.text).to have_content(maintainer.name) + expect(second_row.text).to have_content(developer.name) + + expect_sort_by('Created on', :asc) + end + + it 'sorts by user created on descending' do + visit_members_list(sort: :recent_created_user) + + expect(first_row.text).to have_content(developer.name) + expect(second_row.text).to have_content(maintainer.name) + + expect_sort_by('Created on', :desc) + end + + it 'sorts by last activity ascending' do + visit_members_list(sort: :oldest_last_activity) + + expect(first_row.text).to have_content(developer.name) + expect(second_row.text).to have_content(maintainer.name) + + expect_sort_by('Last activity', :asc) + end + + it 'sorts by last activity descending' do + visit_members_list(sort: :recent_last_activity) + + expect(first_row.text).to have_content(maintainer.name) + expect(second_row.text).to have_content(developer.name) + + expect_sort_by('Last activity', :desc) + end + it 'sorts by access granted ascending' do visit_members_list(sort: :last_joined) diff --git a/spec/features/projects/milestones/milestones_sorting_spec.rb b/spec/features/projects/milestones/milestones_sorting_spec.rb index 565c61cfaa0..2ad820e4a06 100644 --- a/spec/features/projects/milestones/milestones_sorting_spec.rb +++ b/spec/features/projects/milestones/milestones_sorting_spec.rb @@ -5,49 +5,55 @@ require 'spec_helper' RSpec.describe 'Milestones sorting', :js do let(:user) { create(:user) } let(:project) { create(:project, name: 'test', namespace: user.namespace) } + let(:milestones_for_sort_by) do + { + 'Due later' => %w[b c a], + 'Name, ascending' => %w[a b c], + 'Name, descending' => %w[c b a], + 'Start later' => %w[a c b], + 'Start soon' => %w[b c a], + 'Due soon' => %w[a c b] + } + end + + let(:ordered_milestones) do + ['Due soon', 'Due later', 'Start soon', 'Start later', 'Name, ascending', 'Name, descending'] + end before do - # Milestones - create(:milestone, - due_date: 10.days.from_now, - created_at: 2.hours.ago, - title: "aaa", project: project) - create(:milestone, - due_date: 11.days.from_now, - created_at: 1.hour.ago, - title: "bbb", project: project) + create(:milestone, start_date: 7.days.from_now, due_date: 10.days.from_now, title: "a", project: project) + create(:milestone, start_date: 6.days.from_now, due_date: 11.days.from_now, title: "c", project: project) + create(:milestone, start_date: 5.days.from_now, due_date: 12.days.from_now, title: "b", project: project) sign_in(user) end - it 'visit project milestones and sort by due_date_asc' do + it 'visit project milestones and sort by various orders' do visit project_milestones_path(project) expect(page).to have_button('Due soon') - # assert default sorting + # assert default sorting order within '.milestones' do - expect(page.all('ul.content-list > li').first.text).to include('aaa') - expect(page.all('ul.content-list > li').last.text).to include('bbb') + expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(%w[a c b]) end - click_button 'Due soon' + # assert milestones listed for given sort order + selected_sort_order = 'Due soon' + milestones_for_sort_by.each do |sort_by, expected_milestones| + within '[data-testid=milestone_sort_by_dropdown]' do + click_button selected_sort_order + milestones = find('.gl-new-dropdown-contents').all('.gl-new-dropdown-item-text-wrapper p').map(&:text) + expect(milestones).to eq(ordered_milestones) - sort_options = find('ul.dropdown-menu-sort li').all('a').collect(&:text) + click_button sort_by + expect(page).to have_button(sort_by) + end - expect(sort_options[0]).to eq('Due soon') - expect(sort_options[1]).to eq('Due later') - expect(sort_options[2]).to eq('Start soon') - expect(sort_options[3]).to eq('Start later') - expect(sort_options[4]).to eq('Name, ascending') - expect(sort_options[5]).to eq('Name, descending') + within '.milestones' do + expect(page.all('ul.content-list > li strong > a').map(&:text)).to eq(expected_milestones) + end - click_link 'Due later' - - expect(page).to have_button('Due later') - - within '.milestones' do - expect(page.all('ul.content-list > li').first.text).to include('bbb') - expect(page.all('ul.content-list > li').last.text).to include('aaa') + selected_sort_order = sort_by end end end diff --git a/spec/features/projects/new_project_spec.rb b/spec/features/projects/new_project_spec.rb index c57e39b6508..0046dfe436f 100644 --- a/spec/features/projects/new_project_spec.rb +++ b/spec/features/projects/new_project_spec.rb @@ -191,7 +191,8 @@ RSpec.describe 'New project', :js do click_link 'Create blank project' end - it 'selects the user namespace' do + it 'does not select the user namespace' do + click_on 'Pick a group or namespace' expect(page).to have_button user.username end end @@ -328,6 +329,14 @@ RSpec.describe 'New project', :js do click_on 'Create project' + expect(page).to have_content( + s_('ProjectsNew|Pick a group or namespace where you want to create this project.') + ) + + click_on 'Pick a group or namespace' + click_on user.username + click_on 'Create project' + expect(page).to have_css('#import-project-pane.active') expect(page).not_to have_css('.toggle-import-form.hide') end diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 6b9dfdf3a7b..219c8ec0070 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -15,6 +15,7 @@ RSpec.describe 'Pipeline', :js do before do sign_in(user) project.add_role(user, role) + stub_feature_flags(pipeline_tabs_vue: false) end shared_context 'pipeline builds' do @@ -356,6 +357,7 @@ RSpec.describe 'Pipeline', :js do context 'page tabs' do before do + stub_feature_flags(pipeline_tabs_vue: false) visit_pipeline end @@ -388,6 +390,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, :with_test_reports, :with_report_results, project: project) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit_pipeline wait_for_requests end @@ -924,6 +927,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit builds_project_pipeline_path(project, pipeline) end @@ -944,6 +948,10 @@ RSpec.describe 'Pipeline', :js do end context 'page tabs' do + before do + stub_feature_flags(pipeline_tabs_vue: false) + end + it 'shows Pipeline, Jobs and DAG tabs with link' do expect(page).to have_link('Pipeline') expect(page).to have_link('Jobs') @@ -1014,6 +1022,10 @@ RSpec.describe 'Pipeline', :js do end describe 'GET /:project/-/pipelines/:id/failures' do + before do + stub_feature_flags(pipeline_tabs_vue: false) + end + let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: '1234') } let(:pipeline_failures_page) { failures_project_pipeline_path(project, pipeline) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline) } @@ -1139,6 +1151,7 @@ RSpec.describe 'Pipeline', :js do let(:pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } before do + stub_feature_flags(pipeline_tabs_vue: false) visit dag_project_pipeline_path(project, pipeline) end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 0e1728858ec..8b1a22ae05a 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -623,6 +623,7 @@ RSpec.describe 'Pipelines', :js do create(:generic_commit_status, pipeline: pipeline, stage: 'external', name: 'jenkins', stage_idx: 3, ref: 'master') + stub_feature_flags(pipeline_tabs_vue: false) visit project_pipeline_path(project, pipeline) wait_for_requests end diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb index 98935fdf872..a7348b62fc0 100644 --- a/spec/features/projects/releases/user_views_releases_spec.rb +++ b/spec/features/projects/releases/user_views_releases_spec.rb @@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do stub_default_url_options(host: 'localhost') end - shared_examples 'releases index page' do - context('when the user is a maintainer') do - before do - sign_in(maintainer) + context('when the user is a maintainer') do + before do + sign_in(maintainer) - visit project_releases_path(project) + visit project_releases_path(project) - wait_for_requests - end + wait_for_requests + end - 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 + 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 - it 'renders the correct links', :aggregate_failures do - page.within("##{release_v1.tag} .js-assets-list") do - external_link_indicator_selector = '[data-testid="external-link-indicator"]' + it 'renders the correct links', :aggregate_failures do + page.within("##{release_v1.tag} .js-assets-list") do + external_link_indicator_selector = '[data-testid="external-link-indicator"]' - expect(page).to have_link internal_link.name, href: internal_link.url - expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link.name, href: internal_link.url + expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" - expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" + expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link external_link.name, href: external_link.url - expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) - end + expect(page).to have_link external_link.name, href: external_link.url + expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) end + end - context 'with an upcoming release' do - it 'sees the upcoming tag' do - page.within("##{release_v3.tag}") do - expect(page).to have_content('Upcoming Release') - end + context 'with an upcoming release' do + it 'sees the upcoming tag' do + 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 - page.within("##{release_v2.tag.parameterize}") do - expect(page).to have_content(release_v2.name) - expect(page).to have_content(release_v2.tag) - end + context 'with a tag containing a slash' do + it 'sees the release' do + 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 'sorting' do + def sort_page(by:, direction:) + within '[data-testid="releases-sort"]' do + find('.dropdown-toggle').click - context "when the page is sorted by the default sort order" do - let(:expected_releases) { [release_v3, release_v2, release_v1] } + click_button(by, class: 'dropdown-item') - it_behaves_like 'releases sort order' + find('.sorting-direction-button').click if direction == :ascending end + end - context "when the page is sorted by created_at ascending " do - let(:expected_releases) { [release_v2, release_v1, release_v3] } + shared_examples 'releases sort order' do + it "sorts the releases #{description}" do + card_titles = page.all('.release-block .card-title', minimum: expected_releases.count) - before do - sort_page by: 'Created date', direction: :ascending + card_titles.each_with_index do |title, index| + expect(title).to have_content(expected_releases[index].name) end - - it_behaves_like 'releases sort order' end end - end - context('when the user is a guest') do - before do - sign_in(guest) - end + context "when the page is sorted by the default sort order" do + let(:expected_releases) { [release_v3, release_v2, release_v1] } - it 'renders release info except for Git-related data' do - visit project_releases_path(project) + it_behaves_like 'releases sort order' + end - within('.release-block', match: :first) do - expect(page).to have_content(release_v3.description) - expect(page).to have_content(release_v3.tag) - expect(page).to have_content(release_v3.name) + context "when the page is sorted by created_at ascending " do + let(:expected_releases) { [release_v2, release_v1, release_v3] } - # The following properties (sometimes) include Git info, - # so they are not rendered for Guest users - expect(page).not_to have_content(release_v3.commit.short_id) + before do + sort_page by: 'Created date', direction: :ascending end + + it_behaves_like 'releases sort order' end end end - context 'when the releases_index_apollo_client feature flag is enabled' do + context('when the user is a guest') do before do - stub_feature_flags(releases_index_apollo_client: true) + sign_in(guest) end - it_behaves_like 'releases index page' - end + it 'renders release info except for Git-related data' do + visit project_releases_path(project) - context 'when the releases_index_apollo_client feature flag is disabled' do - before do - stub_feature_flags(releases_index_apollo_client: false) - end + within('.release-block', match: :first) do + expect(page).to have_content(release_v3.description) + expect(page).to have_content(release_v3.tag) + expect(page).to have_content(release_v3.name) - it_behaves_like 'releases index page' + # The following properties (sometimes) include Git info, + # so they are not rendered for Guest users + expect(page).not_to have_content(release_v3.commit.short_id) + end + end end end diff --git a/spec/features/projects/terraform_spec.rb b/spec/features/projects/terraform_spec.rb index 2c63f2bfc02..d9e45b5e78e 100644 --- a/spec/features/projects/terraform_spec.rb +++ b/spec/features/projects/terraform_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Terraform', :js do end it 'sees an empty state' do - expect(page).to have_content('Get started with Terraform') + expect(page).to have_content("Your project doesn't have any Terraform state files") end end diff --git a/spec/features/projects/user_creates_project_spec.rb b/spec/features/projects/user_creates_project_spec.rb index 6491a7425f7..b07f2d12660 100644 --- a/spec/features/projects/user_creates_project_spec.rb +++ b/spec/features/projects/user_creates_project_spec.rb @@ -33,29 +33,6 @@ RSpec.describe 'User creates a project', :js do end it 'creates a new project that is not blank' do - stub_experiments(new_project_sast_enabled: 'candidate') - - visit(new_project_path) - - click_link 'Create blank project' - fill_in(:project_name, with: 'With initial commits') - - expect(page).to have_checked_field 'Initialize repository with a README' - expect(page).to have_checked_field 'Enable Static Application Security Testing (SAST)' - - click_button('Create project') - - project = Project.last - - expect(page).to have_current_path(project_path(project), ignore_query: true) - expect(page).to have_content('With initial commits') - expect(page).to have_content('Configure SAST in `.gitlab-ci.yml`, creating this file if it does not already exist') - expect(page).to have_content('README.md Initial commit') - end - - it 'allows creating a new project when the new_project_sast_enabled is assigned the unchecked candidate' do - stub_experiments(new_project_sast_enabled: 'unchecked_candidate') - visit(new_project_path) click_link 'Create blank project' @@ -93,7 +70,7 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'A Subgroup Project' fill_in :project_path, with: 'a-subgroup-project' - click_button user.username + click_on 'Pick a group or namespace' click_button subgroup.full_path click_button('Create project') @@ -120,9 +97,6 @@ RSpec.describe 'User creates a project', :js do fill_in :project_name, with: 'a-new-project' fill_in :project_path, with: 'a-new-project' - click_button user.username - click_button group.full_path - page.within('#content-body') do click_button('Create project') end diff --git a/spec/features/projects/user_sorts_projects_spec.rb b/spec/features/projects/user_sorts_projects_spec.rb index 71e43467a39..7c970f7ee3d 100644 --- a/spec/features/projects/user_sorts_projects_spec.rb +++ b/spec/features/projects/user_sorts_projects_spec.rb @@ -14,25 +14,29 @@ RSpec.describe 'User sorts projects and order persists' do it "is set on the dashboard_projects_path" do visit(dashboard_projects_path) - expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label) + expect(find('#sort-projects-dropdown')).to have_content(project_paths_label) end it "is set on the explore_projects_path" do visit(explore_projects_path) - expect(find('.dropdown-menu a.is-active', text: project_paths_label)).to have_content(project_paths_label) + expect(find('#sort-projects-dropdown')).to have_content(project_paths_label) end it "is set on the group_canonical_path" do visit(group_canonical_path(group)) - expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label) + within '[data-testid=group_sort_by_dropdown]' do + expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label) + end end it "is set on the details_group_path" do visit(details_group_path(group)) - expect(find('.dropdown-menu a.is-active', text: group_paths_label)).to have_content(group_paths_label) + within '[data-testid=group_sort_by_dropdown]' do + expect(find('.gl-dropdown-toggle')).to have_content(group_paths_label) + end end end @@ -58,23 +62,27 @@ RSpec.describe 'User sorts projects and order persists' do it_behaves_like "sort order persists across all views", "Name", "Name" end - context 'from group homepage' do + context 'from group homepage', :js do before do sign_in(user) visit(group_canonical_path(group)) - find('button.dropdown-menu-toggle').click - first(:link, 'Last created').click + within '[data-testid=group_sort_by_dropdown]' do + find('button.gl-dropdown-toggle').click + first(:button, 'Last created').click + end end it_behaves_like "sort order persists across all views", "Created date", "Last created" end - context 'from group details' do + context 'from group details', :js do before do sign_in(user) visit(details_group_path(group)) - find('button.dropdown-menu-toggle').click - first(:link, 'Most stars').click + within '[data-testid=group_sort_by_dropdown]' do + find('button.gl-dropdown-toggle').click + first(:button, 'Most stars').click + end end it_behaves_like "sort order persists across all views", "Stars", "Most stars" diff --git a/spec/features/projects_spec.rb b/spec/features/projects_spec.rb index 1049f8bc18f..db64f84aa76 100644 --- a/spec/features/projects_spec.rb +++ b/spec/features/projects_spec.rb @@ -15,6 +15,12 @@ RSpec.describe 'Project' do end shared_examples 'creates from template' do |template, sub_template_tab = nil| + let(:selected_template) { page.find('.project-fields-form .selected-template') } + + choose_template_selector = '.choose-template' + template_option_selector = '.template-option' + template_name_selector = '.description strong' + it "is created from template", :js do click_link 'Create from template' find(".project-template #{sub_template_tab}").click if sub_template_tab @@ -27,6 +33,39 @@ RSpec.describe 'Project' do expect(page).to have_content template.name end + + it 'is created using keyboard navigation', :js do + click_link 'Create from template' + + first_template = first(template_option_selector) + first_template_name = first_template.find(template_name_selector).text + first_template.find(choose_template_selector).click + + expect(selected_template).to have_text(first_template_name) + + click_button "Change template" + find("#built-in").click + + # Jumps down 1 template, skipping the `preview` buttons + 2.times do + page.send_keys :tab + end + + # Ensure the template with focus is selected + project_name = "project from template" + focused_template = page.find(':focus').ancestor(template_option_selector) + focused_template_name = focused_template.find(template_name_selector).text + focused_template.find(choose_template_selector).send_keys :enter + fill_in "project_name", with: project_name + + expect(selected_template).to have_text(focused_template_name) + + page.within '#content-body' do + click_button "Create project" + end + + expect(page).to have_content project_name + end end context 'create with project template' do diff --git a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb b/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb deleted file mode 100644 index 3638e98a08a..00000000000 --- a/spec/features/refactor_blob_viewer_disabled/projects/blobs/balsamiq_spec.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Balsamiq file blob', :js do - let(:project) { create(:project, :public, :repository) } - - before do - stub_feature_flags(refactor_blob_viewer: false) - visit project_blob_path(project, 'add-balsamiq-file/files/images/balsamiq.bmpr') - - wait_for_requests - end - - it 'displays Balsamiq file content' do - expect(page).to have_content("Mobile examples") - end -end diff --git a/spec/features/runners_spec.rb b/spec/features/runners_spec.rb index 49c468976b9..2dddcd62a6c 100644 --- a/spec/features/runners_spec.rb +++ b/spec/features/runners_spec.rb @@ -352,6 +352,7 @@ RSpec.describe 'Runners' do before do group.add_owner(user) + stub_feature_flags(runner_list_group_view_vue_ui: false) end context 'group with no runners' do diff --git a/spec/features/search/user_searches_for_projects_spec.rb b/spec/features/search/user_searches_for_projects_spec.rb index c38ad077cd0..562da56275c 100644 --- a/spec/features/search/user_searches_for_projects_spec.rb +++ b/spec/features/search/user_searches_for_projects_spec.rb @@ -8,6 +8,8 @@ RSpec.describe 'User searches for projects', :js do context 'when signed out' do context 'when block_anonymous_global_searches is disabled' do before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) stub_feature_flags(block_anonymous_global_searches: false) end diff --git a/spec/features/search/user_uses_header_search_field_spec.rb b/spec/features/search/user_uses_header_search_field_spec.rb index 8736f16b991..7350a54e8df 100644 --- a/spec/features/search/user_uses_header_search_field_spec.rb +++ b/spec/features/search/user_uses_header_search_field_spec.rb @@ -17,12 +17,15 @@ RSpec.describe 'User uses header search field', :js do end before do + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit).and_return(1000) + allow(Gitlab::ApplicationRateLimiter).to receive(:threshold).with(:search_rate_limit_unauthenticated).and_return(1000) sign_in(user) end shared_examples 'search field examples' do before do visit(url) + wait_for_all_requests end it 'starts searching by pressing the enter key' do @@ -37,7 +40,6 @@ RSpec.describe 'User uses header search field', :js do before do find('#search') find('body').native.send_keys('s') - wait_for_all_requests end @@ -49,6 +51,7 @@ RSpec.describe 'User uses header search field', :js do context 'when clicking the search field' do before do page.find('#search').click + wait_for_all_requests end it 'shows category search dropdown', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/250285' do @@ -59,7 +62,7 @@ RSpec.describe 'User uses header search field', :js do let!(:issue) { create(:issue, project: project, author: user, assignees: [user]) } it 'shows assigned issues' do - find('.search-input-container .dropdown-menu').click_link('Issues assigned to me') + find('[data-testid="header-search-dropdown-menu"]').click_link('Issues assigned to me') expect(page).to have_selector('.issues-list .issue') expect_tokens([assignee_token(user.name)]) @@ -67,7 +70,7 @@ RSpec.describe 'User uses header search field', :js do end it 'shows created issues' do - find('.search-input-container .dropdown-menu').click_link("Issues I've created") + find('[data-testid="header-search-dropdown-menu"]').click_link("Issues I've created") expect(page).to have_selector('.issues-list .issue') expect_tokens([author_token(user.name)]) @@ -79,7 +82,7 @@ RSpec.describe 'User uses header search field', :js do let!(:merge_request) { create(:merge_request, source_project: project, author: user, assignees: [user]) } it 'shows assigned merge requests' do - find('.search-input-container .dropdown-menu').click_link('Merge requests assigned to me') + find('[data-testid="header-search-dropdown-menu"]').click_link('Merge requests assigned to me') expect(page).to have_selector('.mr-list .merge-request') expect_tokens([assignee_token(user.name)]) @@ -87,7 +90,7 @@ RSpec.describe 'User uses header search field', :js do end it 'shows created merge requests' do - find('.search-input-container .dropdown-menu').click_link("Merge requests I've created") + find('[data-testid="header-search-dropdown-menu"]').click_link("Merge requests I've created") expect(page).to have_selector('.mr-list .merge-request') expect_tokens([author_token(user.name)]) @@ -150,10 +153,9 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - - expect(page).to have_selector(scoped_search_link('test')) - expect(page).to have_selector(scoped_search_link('test', group_id: group.id)) - expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id)) + expect(page).to have_selector(scoped_search_link('test', search_code: true)) + expect(page).to have_selector(scoped_search_link('test', group_id: group.id, search_code: true)) + expect(page).to have_selector(scoped_search_link('test', project_id: project.id, group_id: group.id, search_code: true)) end end @@ -165,10 +167,9 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - - expect(page).to have_selector(scoped_search_link('test')) - 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)) + expect(page).to have_selector(scoped_search_link('test', search_code: true, repository_ref: 'master')) + expect(page).not_to have_selector(scoped_search_link('test', search_code: true, group_id: project.namespace_id, repository_ref: 'master')) + expect(page).to have_selector(scoped_search_link('test', search_code: true, project_id: project.id, repository_ref: 'master')) end it 'displays a link to project merge requests' do @@ -217,7 +218,6 @@ RSpec.describe 'User uses header search field', :js do it 'displays search options' do fill_in_search('test') - expect(page).to have_selector(scoped_search_link('test')) expect(page).to have_selector(scoped_search_link('test', group_id: group.id)) expect(page).not_to have_selector(scoped_search_link('test', project_id: project.id)) @@ -248,18 +248,20 @@ RSpec.describe 'User uses header search field', :js do end end - def scoped_search_link(term, project_id: nil, group_id: nil) + def scoped_search_link(term, project_id: nil, group_id: nil, search_code: nil, repository_ref: nil) # search_path will accept group_id and project_id but the order does not match # what is expected in the href, so the variable must be built manually href = search_path(search: term) + href.concat("&nav_source=navbar") href.concat("&project_id=#{project_id}") if project_id href.concat("&group_id=#{group_id}") if group_id - href.concat("&nav_source=navbar") + href.concat("&search_code=true") if search_code + href.concat("&repository_ref=#{repository_ref}") if repository_ref - ".dropdown a[href='#{href}']" + "[data-testid='header-search-dropdown-menu'] a[href='#{href}']" end def dashboard_search_options_popup_menu - "div[data-testid='dashboard-search-options']" + "[data-testid='header-search-dropdown-menu'] .header-search-dropdown-content" end end diff --git a/spec/features/static_site_editor_spec.rb b/spec/features/static_site_editor_spec.rb deleted file mode 100644 index 98313905a33..00000000000 --- a/spec/features/static_site_editor_spec.rb +++ /dev/null @@ -1,113 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe 'Static Site Editor' do - include ContentSecurityPolicyHelpers - - let_it_be(:user) { create(:user) } - let_it_be(:project) { create(:project, :public, :repository) } - - let(:sse_path) { project_show_sse_path(project, 'master/README.md') } - - before_all do - project.add_developer(user) - end - - before do - sign_in(user) - end - - context "when no config file is present" do - before do - visit sse_path - end - - it 'renders SSE page with all generated config values and default config file values' do - node = page.find('#static-site-editor') - - # assert generated config values are present - expect(node['data-base-url']).to eq("/#{project.full_path}/-/sse/master%2FREADME.md") - expect(node['data-branch']).to eq('master') - expect(node['data-commit-id']).to match(/\A[0-9a-f]{40}\z/) - expect(node['data-is-supported-content']).to eq('true') - expect(node['data-merge-requests-illustration-path']) - .to match(%r{/assets/illustrations/merge_requests-.*\.svg}) - expect(node['data-namespace']).to eq(project.namespace.full_path) - expect(node['data-project']).to eq(project.path) - expect(node['data-project-id']).to eq(project.id.to_s) - - # assert default config file values are present - expect(node['data-image-upload-path']).to eq('source/images') - expect(node['data-mounts']).to eq('[{"source":"source","target":""}]') - expect(node['data-static-site-generator']).to eq('middleman') - end - end - - context "when a config file is present" do - let(:config_file_yml) do - <<~YAML - image_upload_path: custom-image-upload-path - mounts: - - source: source1 - target: "" - - source: source2 - target: target2 - static_site_generator: middleman - YAML - end - - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:blob_data_at).and_return(config_file_yml) - end - - visit sse_path - end - - it 'renders Static Site Editor page values read from config file' do - node = page.find('#static-site-editor') - - # assert user-specified config file values are present - expected_mounts = '[{"source":"source1","target":""},{"source":"source2","target":"target2"}]' - expect(node['data-image-upload-path']).to eq('custom-image-upload-path') - expect(node['data-mounts']).to eq(expected_mounts) - 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 - setup_csp_for_controller(Projects::StaticSiteEditorController) - 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 - - setup_existing_csp_for_controller(Projects::StaticSiteEditorController, csp) - 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/task_lists_spec.rb b/spec/features/task_lists_spec.rb index 0f8daaf8e15..6907701de9c 100644 --- a/spec/features/task_lists_spec.rb +++ b/spec/features/task_lists_spec.rb @@ -22,7 +22,7 @@ RSpec.describe 'Task Lists', :js do MARKDOWN end - let(:singleIncompleteMarkdown) do + let(:single_incomplete_markdown) do <<-MARKDOWN.strip_heredoc This is a task list: @@ -30,7 +30,7 @@ RSpec.describe 'Task Lists', :js do MARKDOWN end - let(:singleCompleteMarkdown) do + let(:single_complete_markdown) do <<-MARKDOWN.strip_heredoc This is a task list: @@ -94,7 +94,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single incomplete task' do - let!(:issue) { create(:issue, description: singleIncompleteMarkdown, author: user, project: project) } + let!(:issue) { create(:issue, description: single_incomplete_markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) @@ -113,7 +113,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single complete task' do - let!(:issue) { create(:issue, description: singleCompleteMarkdown, author: user, project: project) } + let!(:issue) { create(:issue, description: single_complete_markdown, author: user, project: project) } it 'renders' do visit_issue(project, issue) @@ -171,7 +171,7 @@ RSpec.describe 'Task Lists', :js do describe 'single incomplete task' do let!(:note) do - create(:note, note: singleIncompleteMarkdown, noteable: issue, + create(:note, note: single_incomplete_markdown, noteable: issue, project: project, author: user) end @@ -186,7 +186,7 @@ RSpec.describe 'Task Lists', :js do describe 'single complete task' do let!(:note) do - create(:note, note: singleCompleteMarkdown, noteable: issue, + create(:note, note: single_complete_markdown, noteable: issue, project: project, author: user) end @@ -264,7 +264,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single incomplete task' do - let!(:merge) { create(:merge_request, :simple, description: singleIncompleteMarkdown, author: user, source_project: project) } + let!(:merge) { create(:merge_request, :simple, description: single_incomplete_markdown, author: user, source_project: project) } it 'renders for description' do visit_merge_request(project, merge) @@ -283,7 +283,7 @@ RSpec.describe 'Task Lists', :js do end describe 'single complete task' do - let!(:merge) { create(:merge_request, :simple, description: singleCompleteMarkdown, author: user, source_project: project) } + let!(:merge) { create(:merge_request, :simple, description: single_complete_markdown, author: user, source_project: project) } it 'renders for description' do visit_merge_request(project, merge) diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index 8610cae58a4..822bf898034 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -818,7 +818,6 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do context 'when 2FA is required for the user' do before do - stub_feature_flags(mr_attention_requests: false) group = create(:group, require_two_factor_authentication: true) group.add_developer(user) end @@ -840,7 +839,15 @@ RSpec.describe 'Login', :clean_gitlab_redis_sessions do expect(page).to have_current_path(profile_two_factor_auth_path, ignore_query: true) - fill_in 'pin_code', with: user.reload.current_otp + # Use the secret shown on the page to generate the OTP that will be entered. + # This detects issues wherein a new secret gets generated after the + # page is shown. + wait_for_requests + + otp_secret = page.find('.two-factor-secret').text.gsub('Key:', '').delete(' ') + current_otp = ROTP::TOTP.new(otp_secret).now + + fill_in 'pin_code', with: current_otp fill_in 'current_password', with: user.password click_button 'Register with two-factor app' diff --git a/spec/finders/bulk_imports/entities_finder_spec.rb b/spec/finders/bulk_imports/entities_finder_spec.rb index e053011b60d..54c792cb4d8 100644 --- a/spec/finders/bulk_imports/entities_finder_spec.rb +++ b/spec/finders/bulk_imports/entities_finder_spec.rb @@ -51,7 +51,7 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when status is specified' do - subject { described_class.new(user: user, status: 'failed') } + subject { described_class.new(user: user, params: { status: 'failed' }) } it 'returns a list of import entities filtered by status' do expect(subject.execute) @@ -61,7 +61,7 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when invalid status is specified' do - subject { described_class.new(user: user, status: 'invalid') } + subject { described_class.new(user: user, params: { status: 'invalid' }) } it 'does not filter entities by status' do expect(subject.execute) @@ -74,11 +74,37 @@ RSpec.describe BulkImports::EntitiesFinder do end context 'when bulk import and status are specified' do - subject { described_class.new(user: user, bulk_import: user_import_2, status: 'finished') } + subject { described_class.new(user: user, bulk_import: user_import_2, params: { status: 'finished' }) } it 'returns matched import entities' do expect(subject.execute).to contain_exactly(finished_entity_2) end end + + context 'when order is specifed' do + subject { described_class.new(user: user, params: { sort: order }) } + + context 'when order is specified as asc' do + let(:order) { :asc } + + it 'returns entities sorted ascending' do + expect(subject.execute).to eq([ + started_entity_1, finished_entity_1, failed_entity_1, + started_entity_2, finished_entity_2, failed_entity_2 + ]) + end + end + + context 'when order is specified as desc' do + let(:order) { :desc } + + it 'returns entities sorted descending' do + expect(subject.execute).to eq([ + failed_entity_2, finished_entity_2, started_entity_2, + failed_entity_1, finished_entity_1, started_entity_1 + ]) + end + end + end end end diff --git a/spec/finders/bulk_imports/imports_finder_spec.rb b/spec/finders/bulk_imports/imports_finder_spec.rb index aac83c86c84..2f550514a33 100644 --- a/spec/finders/bulk_imports/imports_finder_spec.rb +++ b/spec/finders/bulk_imports/imports_finder_spec.rb @@ -16,19 +16,39 @@ RSpec.describe BulkImports::ImportsFinder do end context 'when status is specified' do - subject { described_class.new(user: user, status: 'started') } + subject { described_class.new(user: user, params: { status: 'started' }) } it 'returns a list of import entities filtered by status' do expect(subject.execute).to contain_exactly(started_import) end context 'when invalid status is specified' do - subject { described_class.new(user: user, status: 'invalid') } + subject { described_class.new(user: user, params: { status: 'invalid' }) } it 'does not filter entities by status' do expect(subject.execute).to contain_exactly(started_import, finished_import) end end end + + context 'when order is specifed' do + subject { described_class.new(user: user, params: { sort: order }) } + + context 'when order is specified as asc' do + let(:order) { :asc } + + it 'returns entities sorted ascending' do + expect(subject.execute).to eq([started_import, finished_import]) + end + end + + context 'when order is specified as desc' do + let(:order) { :desc } + + it 'returns entities sorted descending' do + expect(subject.execute).to eq([finished_import, started_import]) + end + end + end end end diff --git a/spec/finders/ci/jobs_finder_spec.rb b/spec/finders/ci/jobs_finder_spec.rb index 959716b1fd3..45e8cf5a582 100644 --- a/spec/finders/ci/jobs_finder_spec.rb +++ b/spec/finders/ci/jobs_finder_spec.rb @@ -7,9 +7,9 @@ RSpec.describe Ci::JobsFinder, '#execute' do let_it_be(:admin) { create(:user, :admin) } let_it_be(:project) { create(:project, :private, public_builds: false) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } - let_it_be(:job_1) { create(:ci_build) } - let_it_be(:job_2) { create(:ci_build, :running) } - let_it_be(:job_3) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } + let_it_be(:pending_job) { create(:ci_build, :pending) } + let_it_be(:running_job) { create(:ci_build, :running) } + let_it_be(:successful_job) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } let(:params) { {} } @@ -17,7 +17,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do subject { described_class.new(current_user: admin, params: params).execute } it 'returns all jobs' do - expect(subject).to match_array([job_1, job_2, job_3]) + expect(subject).to match_array([pending_job, running_job, successful_job]) end context 'non admin user' do @@ -37,7 +37,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do end context 'scope is present' do - let(:jobs) { [job_1, job_2, job_3] } + let(:jobs) { [pending_job, running_job, successful_job] } where(:scope, :index) do [ @@ -55,11 +55,11 @@ RSpec.describe Ci::JobsFinder, '#execute' do end context 'scope is an array' do - let(:jobs) { [job_1, job_2, job_3] } - let(:params) {{ scope: ['running'] }} + let(:jobs) { [pending_job, running_job, successful_job, canceled_job] } + let(:params) {{ scope: %w'running success' }} it 'filters by the job statuses in the scope' do - expect(subject).to match_array([job_2]) + expect(subject).to contain_exactly(running_job, successful_job) end end end @@ -73,7 +73,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do end it 'returns jobs for the specified project' do - expect(subject).to match_array([job_3]) + expect(subject).to match_array([successful_job]) end end @@ -99,7 +99,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do context 'when pipeline is present' do before_all do project.add_maintainer(user) - job_3.update!(retried: true) + successful_job.update!(retried: true) end let_it_be(:job_4) { create(:ci_build, :success, pipeline: pipeline, name: 'build') } @@ -122,7 +122,7 @@ RSpec.describe Ci::JobsFinder, '#execute' do let(:params) { { include_retried: true } } it 'returns retried jobs' do - expect(subject).to match_array([job_3, job_4]) + expect(subject).to match_array([successful_job, job_4]) end end end diff --git a/spec/finders/concerns/finder_methods_spec.rb b/spec/finders/concerns/finder_methods_spec.rb index 195449d70c3..09ec8110129 100644 --- a/spec/finders/concerns/finder_methods_spec.rb +++ b/spec/finders/concerns/finder_methods_spec.rb @@ -12,7 +12,7 @@ RSpec.describe FinderMethods do end def execute - Project.all.order(id: :desc) + Project.where.not(name: 'foo').order(id: :desc) end private @@ -21,22 +21,30 @@ RSpec.describe FinderMethods do end end - let(:user) { create(:user) } - let(:finder) { finder_class.new(user) } - let(:authorized_project) { create(:project) } - let(:unauthorized_project) { create(:project) } + let_it_be(:user) { create(:user) } + let_it_be(:authorized_project) { create(:project) } + let_it_be(:unmatched_project) { create(:project, name: 'foo') } + let_it_be(:unauthorized_project) { create(:project) } - before do + subject(:finder) { finder_class.new(user) } + + before_all do authorized_project.add_developer(user) + unmatched_project.add_developer(user) end + # rubocop:disable Rails/FindById describe '#find_by!' do it 'returns the project if the user has access' do expect(finder.find_by!(id: authorized_project.id)).to eq(authorized_project) end - it 'raises not found when the project is not found' do - expect { finder.find_by!(id: 0) }.to raise_error(ActiveRecord::RecordNotFound) + it 'raises not found when the project is not found by id' do + expect { finder.find_by!(id: non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises not found when the project is not found by filter' do + expect { finder.find_by!(id: unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end it 'raises not found the user does not have access' do @@ -53,19 +61,34 @@ RSpec.describe FinderMethods do finder.find_by!(id: authorized_project.id) end end + # rubocop:enable Rails/FindById describe '#find' do it 'returns the project if the user has access' do expect(finder.find(authorized_project.id)).to eq(authorized_project) end - it 'raises not found when the project is not found' do - expect { finder.find(0) }.to raise_error(ActiveRecord::RecordNotFound) + it 'raises not found when the project is not found by id' do + expect { finder.find(non_existing_record_id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'raises not found when the project is not found by filter' do + expect { finder.find(unmatched_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end it 'raises not found the user does not have access' do expect { finder.find(unauthorized_project.id) }.to raise_error(ActiveRecord::RecordNotFound) end + + it 'ignores ordering' do + # Memoise the finder result so we can add message expectations to it + relation = finder.execute + allow(finder).to receive(:execute).and_return(relation) + + expect(relation).to receive(:reorder).with(nil).and_call_original + + finder.find(authorized_project.id) + end end describe '#find_by' do @@ -73,8 +96,12 @@ RSpec.describe FinderMethods do expect(finder.find_by(id: authorized_project.id)).to eq(authorized_project) end - it 'returns nil when the project is not found' do - expect(finder.find_by(id: 0)).to be_nil + it 'returns nil when the project is not found by id' do + expect(finder.find_by(id: non_existing_record_id)).to be_nil + end + + it 'returns nil when the project is not found by filter' do + expect(finder.find_by(id: unmatched_project.id)).to be_nil end it 'returns nil when the user does not have access' do diff --git a/spec/finders/concerns/finder_with_cross_project_access_spec.rb b/spec/finders/concerns/finder_with_cross_project_access_spec.rb index 116b523bd99..0798528c200 100644 --- a/spec/finders/concerns/finder_with_cross_project_access_spec.rb +++ b/spec/finders/concerns/finder_with_cross_project_access_spec.rb @@ -93,11 +93,11 @@ RSpec.describe FinderWithCrossProjectAccess do it 'checks the accessibility of the subject directly' do expect_access_check_on_result - finder.find_by!(id: result.id) + finder.find(result.id) end it 're-enables the check after the find failed' do - finder.find_by!(id: non_existing_record_id) rescue ActiveRecord::RecordNotFound + finder.find(non_existing_record_id) rescue ActiveRecord::RecordNotFound expect(finder.instance_variable_get(:@should_skip_cross_project_check)) .to eq(false) diff --git a/spec/finders/keys_finder_spec.rb b/spec/finders/keys_finder_spec.rb index 277c852c953..332aa7afde1 100644 --- a/spec/finders/keys_finder_spec.rb +++ b/spec/finders/keys_finder_spec.rb @@ -5,23 +5,22 @@ require 'spec_helper' RSpec.describe KeysFinder do subject { described_class.new(params).execute } - let(:user) { create(:user) } - let(:params) { {} } - - let!(:key_1) do - create(:personal_key, + let_it_be(:user) { create(:user) } + let_it_be(:key_1) do + create(:rsa_key_4096, last_used_at: 7.days.ago, user: user, - key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1016k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', - fingerprint: 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1', - fingerprint_sha256: 'nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg') + fingerprint: 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7', + fingerprint_sha256: 'ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g') end - let!(:key_2) { create(:personal_key, last_used_at: nil, user: user) } - let!(:key_3) { create(:personal_key, last_used_at: 2.days.ago) } + let_it_be(:key_2) { create(:personal_key_4096, last_used_at: nil, user: user) } + let_it_be(:key_3) { create(:personal_key_4096, last_used_at: 2.days.ago) } + + let(:params) { {} } context 'key_type' do - let!(:deploy_key) { create(:deploy_key) } + let_it_be(:deploy_key) { create(:deploy_key) } context 'when `key_type` is `ssh`' do before do @@ -64,35 +63,41 @@ RSpec.describe KeysFinder do end context 'with valid fingerprints' do - let!(:deploy_key) do - create(:deploy_key, - user: user, - key: 'ssh-rsa AAAAB3NzaC1yc2EAAAABJQAAAIEAiPWx6WM4lhHNedGfBpPJNPpZ7yKu+dnn1SJejgt1017k6YjzGGphH2TUxwKzxcKDKKezwkpfnxPkSMkuEspGRt/aZZ9wa++Oi7Qkr8prgHc4soW6NUlfDzpvZK2H5E7eQaSeP3SAwGmQKUFHCddNaP0L+hM7zhFNzjFvpaMgJw0=', - fingerprint: '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4', - fingerprint_sha256: '4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk') - end + let_it_be(:deploy_key) { create(:rsa_deploy_key_5120, user: user) } context 'personal key with valid MD5 params' do context 'with an existent fingerprint' do before do - params[:fingerprint] = 'ba:81:59:68:d7:6c:cd:02:02:bf:6a:9b:55:4e:af:d1' + params[:fingerprint] = 'df:73:db:29:3c:a5:32:cf:09:17:7e:8e:9d:de:d7:f7' end it 'returns the key' do expect(subject).to eq(key_1) expect(subject.user).to eq(user) end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end context 'deploy key with an existent fingerprint' do before do - params[:fingerprint] = '8a:4a:12:92:0b:50:47:02:d4:5a:8e:a9:44:4e:08:b4' + params[:fingerprint] = 'fe:fa:3a:4d:7d:51:ec:bf:c7:64:0c:96:d0:17:8a:d0' end it 'returns the key' do expect(subject).to eq(deploy_key) expect(subject.user).to eq(user) end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end context 'with a non-existent fingerprint' do @@ -103,13 +108,19 @@ RSpec.describe KeysFinder do it 'returns nil' do expect(subject).to be_nil end + + context 'with FIPS mode', :fips_mode do + it 'raises InvalidFingerprint' do + expect { subject }.to raise_error(KeysFinder::InvalidFingerprint) + end + end end end context 'personal key with valid SHA256 params' do context 'with an existent fingerprint' do before do - params[:fingerprint] = 'SHA256:nUhzNyftwADy8AH3wFY31tAKs7HufskYTte2aXo/lCg' + params[:fingerprint] = 'SHA256:ByDU7hQ1JB95l6p53rHrffc4eXvEtqGUtQhS+Dhyy7g' end it 'returns key' do @@ -120,7 +131,7 @@ RSpec.describe KeysFinder do context 'deploy key with an existent fingerprint' do before do - params[:fingerprint] = 'SHA256:4DPHOVNh53i9dHb5PpY2vjfyf5qniTx1/pBFPoZLDdk' + params[:fingerprint] = 'SHA256:PCCupLbFHScm4AbEufbGDvhBU27IM0MVAor715qKQK8' end it 'returns key' do diff --git a/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb new file mode 100644 index 00000000000..f3c79d0c825 --- /dev/null +++ b/spec/finders/packages/build_infos_for_many_packages_finder_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Packages::BuildInfosForManyPackagesFinder do + using RSpec::Parameterized::TableSyntax + + let_it_be(:package) { create(:package) } + let_it_be(:build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: package) } + let_it_be(:build_info_with_empty_pipeline) { create(:package_build_info, package: package) } + + let_it_be(:other_package) { create(:package) } + let_it_be(:other_build_infos) { create_list(:package_build_info, 5, :with_pipeline, package: other_package) } + let_it_be(:other_build_info_with_empty_pipeline) { create(:package_build_info, package: other_package) } + + let_it_be(:all_build_infos) { build_infos + other_build_infos } + + let(:finder) { described_class.new(packages, params) } + let(:packages) { nil } + let(:first) { nil } + let(:last) { nil } + let(:after) { nil } + let(:before) { nil } + let(:max_page_size) { nil } + let(:support_next_page) { false } + let(:params) do + { + first: first, + last: last, + after: after, + before: before, + max_page_size: max_page_size, + support_next_page: support_next_page + } + end + + describe '#execute' do + subject { finder.execute } + + shared_examples 'returning the expected build infos' do + let(:expected_build_infos) do + expected_build_infos_indexes.map do |idx| + all_build_infos[idx] + end + end + + let(:after) do + all_build_infos[after_index].pipeline_id if after_index + end + + let(:before) do + all_build_infos[before_index].pipeline_id if before_index + end + + it { is_expected.to eq(expected_build_infos) } + end + + context 'with nil packages' do + let(:packages) { nil } + + it { is_expected.to be_empty } + end + + context 'with [] packages' do + let(:packages) { [] } + + it { is_expected.to be_empty } + end + + context 'with empy scope packages' do + let(:packages) { Packages::Package.none } + + it { is_expected.to be_empty } + end + + context 'with a single package' do + let(:packages) { package.id } + + # rubocop: disable Layout/LineLength + where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do + # F L AI BI MPS SNP + nil | nil | nil | nil | nil | false | [4, 3, 2, 1, 0] + nil | nil | nil | nil | 10 | false | [4, 3, 2, 1, 0] + nil | nil | nil | nil | 2 | false | [4, 3] + 2 | nil | nil | nil | nil | false | [4, 3] + 2 | nil | nil | nil | nil | true | [4, 3, 2] + 2 | nil | 3 | nil | nil | false | [2, 1] + 2 | nil | 3 | nil | nil | true | [2, 1, 0] + 3 | nil | 4 | nil | 2 | false | [3, 2] + 3 | nil | 4 | nil | 2 | true | [3, 2, 1] + nil | 2 | nil | nil | nil | false | [1, 0] + nil | 2 | nil | nil | nil | true | [2, 1, 0] + nil | 2 | nil | 1 | nil | false | [3, 2] + nil | 2 | nil | 1 | nil | true | [4, 3, 2] + nil | 3 | nil | 0 | 2 | false | [2, 1] + nil | 3 | nil | 0 | 2 | true | [3, 2, 1] + end + # rubocop: enable Layout/LineLength + + with_them do + it_behaves_like 'returning the expected build infos' + end + end + + context 'with many packages' do + let(:packages) { [package.id, other_package.id] } + + # using after_index/before_index when receiving multiple packages doesn't + # make sense but we still verify here that the behavior is coherent. + # rubocop: disable Layout/LineLength + where(:first, :last, :after_index, :before_index, :max_page_size, :support_next_page, :expected_build_infos_indexes) do + # F L AI BI MPS SNP + nil | nil | nil | nil | nil | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + nil | nil | nil | nil | 10 | false | [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + nil | nil | nil | nil | 2 | false | [9, 8, 4, 3] + 2 | nil | nil | nil | nil | false | [9, 8, 4, 3] + 2 | nil | nil | nil | nil | true | [9, 8, 7, 4, 3, 2] + 2 | nil | 3 | nil | nil | false | [2, 1] + 2 | nil | 3 | nil | nil | true | [2, 1, 0] + 3 | nil | 4 | nil | 2 | false | [3, 2] + 3 | nil | 4 | nil | 2 | true | [3, 2, 1] + nil | 2 | nil | nil | nil | false | [6, 5, 1, 0] + nil | 2 | nil | nil | nil | true | [7, 6, 5, 2, 1, 0] + nil | 2 | nil | 1 | nil | false | [6, 5, 3, 2] + nil | 2 | nil | 1 | nil | true | [7, 6, 5, 4, 3, 2] + nil | 3 | nil | 0 | 2 | false | [6, 5, 2, 1] + nil | 3 | nil | 0 | 2 | true | [7, 6, 5, 3, 2, 1] + end + + with_them do + it_behaves_like 'returning the expected build infos' + end + # rubocop: enable Layout/LineLength + end + end +end diff --git a/spec/finders/packages/group_packages_finder_spec.rb b/spec/finders/packages/group_packages_finder_spec.rb index c2dbfb59eb2..954db6481cd 100644 --- a/spec/finders/packages/group_packages_finder_spec.rb +++ b/spec/finders/packages/group_packages_finder_spec.rb @@ -149,6 +149,22 @@ RSpec.describe Packages::GroupPackagesFinder do it { is_expected.to match_array([package1, package2]) } end + context 'preload_pipelines' do + it 'preloads pipelines by default' do + expect(Packages::Package).to receive(:preload_pipelines).and_call_original + expect(subject).to match_array([package1, package2]) + end + + context 'set to false' do + let(:params) { { preload_pipelines: false } } + + it 'does not preload pipelines' do + expect(Packages::Package).not_to receive(:preload_pipelines) + expect(subject).to match_array([package1, package2]) + end + end + end + context 'with package_name' do let_it_be(:named_package) { create(:maven_package, project: project, name: 'maven') } diff --git a/spec/finders/packages/packages_finder_spec.rb b/spec/finders/packages/packages_finder_spec.rb index b72f4aab3ec..6cea0a44541 100644 --- a/spec/finders/packages/packages_finder_spec.rb +++ b/spec/finders/packages/packages_finder_spec.rb @@ -81,6 +81,22 @@ RSpec.describe ::Packages::PackagesFinder do it { is_expected.to match_array([conan_package, maven_package]) } end + context 'preload_pipelines' do + it 'preloads pipelines by default' do + expect(Packages::Package).to receive(:preload_pipelines).and_call_original + expect(subject).to match_array([maven_package, conan_package]) + end + + context 'set to false' do + let(:params) { { preload_pipelines: false } } + + it 'does not preload pipelines' do + expect(Packages::Package).not_to receive(:preload_pipelines) + expect(subject).to match_array([maven_package, conan_package]) + end + end + end + it_behaves_like 'concerning versionless param' it_behaves_like 'concerning package statuses' end diff --git a/spec/finders/releases/group_releases_finder_spec.rb b/spec/finders/releases/group_releases_finder_spec.rb index b8899a8ee40..5eac6f4fbdc 100644 --- a/spec/finders/releases/group_releases_finder_spec.rb +++ b/spec/finders/releases/group_releases_finder_spec.rb @@ -95,8 +95,6 @@ RSpec.describe Releases::GroupReleasesFinder do end describe 'with subgroups' do - let(:params) { { include_subgroups: true } } - subject(:releases) { described_class.new(group, user, params).execute(**args) } context 'with a single-level subgroup' do @@ -164,22 +162,12 @@ RSpec.describe Releases::GroupReleasesFinder do end end - context 'when the user a guest on the group' do - before do - group.add_guest(user) - end - - it 'returns all releases' do - expect(releases).to match_array([v1_1_1, v1_1_0, v6, v1_0_0, p3]) - end - end - context 'performance testing' do shared_examples 'avoids N+1 queries' do |query_params = {}| context 'with subgroups' do let(:params) { query_params } - it 'include_subgroups avoids N+1 queries' do + it 'subgroups avoids N+1 queries' do control_count = ActiveRecord::QueryRecorder.new(skip_cached: false) do releases end.count @@ -196,7 +184,6 @@ RSpec.describe Releases::GroupReleasesFinder do end it_behaves_like 'avoids N+1 queries' - it_behaves_like 'avoids N+1 queries', { simple: true } end end end diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb index 6019d22059d..d7f7bb9cebe 100644 --- a/spec/finders/user_recent_events_finder_spec.rb +++ b/spec/finders/user_recent_events_finder_spec.rb @@ -8,9 +8,9 @@ RSpec.describe UserRecentEventsFinder do let_it_be(:private_project) { create(:project, :private, creator: project_owner) } let_it_be(:internal_project) { create(:project, :internal, creator: project_owner) } let_it_be(:public_project) { create(:project, :public, creator: project_owner) } - let!(:private_event) { create(:event, project: private_project, author: project_owner) } - let!(:internal_event) { create(:event, project: internal_project, author: project_owner) } - let!(:public_event) { create(:event, project: public_project, author: project_owner) } + let_it_be(:private_event) { create(:event, project: private_project, author: project_owner) } + let_it_be(:internal_event) { create(:event, project: internal_project, author: project_owner) } + let_it_be(:public_event) { create(:event, project: public_project, author: project_owner) } let_it_be(:issue) { create(:issue, project: public_project) } let(:limit) { nil } @@ -18,210 +18,266 @@ RSpec.describe UserRecentEventsFinder do subject(:finder) { described_class.new(current_user, project_owner, nil, params) } - describe '#execute' do - context 'when profile is public' do - it 'returns all the events' do - expect(finder.execute).to include(private_event, internal_event, public_event) + shared_examples 'UserRecentEventsFinder examples' do + describe '#execute' do + context 'when profile is public' do + it 'returns all the events' do + expect(finder.execute).to include(private_event, internal_event, public_event) + end end - end - context 'when profile is private' do - it 'returns no event' do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) + context 'when profile is private' do + it 'returns no event' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) - expect(finder.execute).to be_empty + expect(finder.execute).to be_empty + end end - end - it 'does not include the events if the user cannot read cross project' do - allow(Ability).to receive(:allowed?).and_call_original - expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } + it 'does not include the events if the user cannot read cross project' do + allow(Ability).to receive(:allowed?).and_call_original + expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } - expect(finder.execute).to be_empty - end + expect(finder.execute).to be_empty + end - context 'events from multiple users' do - let_it_be(:second_user, reload: true) { create(:user) } - let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) } + context 'events from multiple users' do + let_it_be(:second_user, reload: true) { create(:user) } + let_it_be(:private_project_second_user) { create(:project, :private, creator: second_user) } - let(:internal_project_second_user) { create(:project, :internal, creator: second_user) } - let(:public_project_second_user) { create(:project, :public, creator: second_user) } - let!(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) } - let!(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) } - let!(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) } + let_it_be(:internal_project_second_user) { create(:project, :internal, creator: second_user) } + let_it_be(:public_project_second_user) { create(:project, :public, creator: second_user) } + let_it_be(:private_event_second_user) { create(:event, project: private_project_second_user, author: second_user) } + let_it_be(:internal_event_second_user) { create(:event, project: internal_project_second_user, author: second_user) } + let_it_be(:public_event_second_user) { create(:event, project: public_project_second_user, author: second_user) } - it 'includes events from all users', :aggregate_failures do - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + it 'includes events from all users', :aggregate_failures do + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to include(private_event, internal_event, public_event) - expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user) - expect(events.size).to eq(6) - end + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(private_event_second_user, internal_event_second_user, public_event_second_user) + expect(events.size).to eq(6) + end - context 'selected events' do - let!(:push_event) { create(:push_event, project: public_project, author: project_owner) } - let!(:push_event_second_user) { create(:push_event, project: public_project_second_user, author: second_user) } + context 'selected events' do + using RSpec::Parameterized::TableSyntax + + let_it_be(:push_event1) { create(:push_event, project: public_project, author: project_owner) } + let_it_be(:push_event2) { create(:push_event, project: public_project_second_user, author: second_user) } + let_it_be(:merge_event1) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project, author: project_owner) } + let_it_be(:merge_event2) { create(:event, :merged, target_type: MergeRequest.to_s, project: public_project_second_user, author: second_user) } + let_it_be(:comment_event1) { create(:event, :commented, target_type: Note.to_s, project: public_project, author: project_owner) } + let_it_be(:comment_event2) { create(:event, :commented, target_type: DiffNote.to_s, project: public_project, author: project_owner) } + let_it_be(:comment_event3) { create(:event, :commented, target_type: DiscussionNote.to_s, project: public_project_second_user, author: second_user) } + let_it_be(:issue_event1) { create(:event, :created, project: public_project, target: issue, author: project_owner) } + let_it_be(:issue_event2) { create(:event, :updated, project: public_project, target: issue, author: project_owner) } + let_it_be(:issue_event3) { create(:event, :closed, project: public_project_second_user, target: issue, author: second_user) } + let_it_be(:wiki_event1) { create(:wiki_page_event, project: public_project, author: project_owner) } + let_it_be(:wiki_event2) { create(:wiki_page_event, project: public_project_second_user, author: second_user) } + let_it_be(:design_event1) { create(:design_event, project: public_project, author: project_owner) } + let_it_be(:design_event2) { create(:design_updated_event, project: public_project_second_user, author: second_user) } + + where(:event_filter, :ordered_expected_events) do + EventFilter.new(EventFilter::PUSH) | lazy { [push_event1, push_event2] } + EventFilter.new(EventFilter::MERGED) | lazy { [merge_event1, merge_event2] } + EventFilter.new(EventFilter::COMMENTS) | lazy { [comment_event1, comment_event2, comment_event3] } + EventFilter.new(EventFilter::TEAM) | lazy { [private_event, internal_event, public_event, private_event_second_user, internal_event_second_user, public_event_second_user] } + EventFilter.new(EventFilter::ISSUE) | lazy { [issue_event1, issue_event2, issue_event3] } + EventFilter.new(EventFilter::WIKI) | lazy { [wiki_event1, wiki_event2] } + EventFilter.new(EventFilter::DESIGNS) | lazy { [design_event1, design_event2] } + end - it 'only includes selected events (PUSH) from all users', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::PUSH) - events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute + with_them do + it 'only returns selected events from all users (id DESC)' do + events = described_class.new(current_user, [project_owner, second_user], event_filter, params).execute - expect(events).to contain_exactly(push_event, push_event_second_user) + expect(events).to eq(ordered_expected_events.reverse) + end + end end - end - it 'does not include events from users with private profile', :aggregate_failures do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false) + it 'does not include events from users with private profile', :aggregate_failures do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, second_user).and_return(false) - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to contain_exactly(private_event, internal_event, public_event) - end + expect(events).to contain_exactly(private_event, internal_event, public_event) + end - context 'with pagination params' do - using RSpec::Parameterized::TableSyntax + context 'with pagination params' do + using RSpec::Parameterized::TableSyntax - where(:limit, :offset, :ordered_expected_events) do - nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] } - 2 | nil | lazy { [public_event_second_user, internal_event_second_user] } - nil | 4 | lazy { [internal_event, private_event] } - 2 | 2 | lazy { [private_event_second_user, public_event] } - end + where(:limit, :offset, :ordered_expected_events) do + nil | nil | lazy { [public_event_second_user, internal_event_second_user, private_event_second_user, public_event, internal_event, private_event] } + 2 | nil | lazy { [public_event_second_user, internal_event_second_user] } + nil | 4 | lazy { [internal_event, private_event] } + 2 | 2 | lazy { [private_event_second_user, public_event] } + end - with_them do - let(:params) { { limit: limit, offset: offset }.compact } + with_them do + let(:params) { { limit: limit, offset: offset }.compact } - it 'returns paginated events sorted by id (DESC)' do - events = described_class.new(current_user, [project_owner, second_user], nil, params).execute + it 'returns paginated events sorted by id (DESC)' do + events = described_class.new(current_user, [project_owner, second_user], nil, params).execute - expect(events).to eq(ordered_expected_events) + expect(events).to eq(ordered_expected_events) + end end end end - end - context 'filter activity events' do - let!(:push_event) { create(:push_event, project: public_project, author: project_owner) } - let!(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) } - let!(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) } - let!(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) } - let!(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) } - let!(:design_event) { create(:design_event, project: public_project, author: project_owner) } - let!(:team_event) { create(:event, :joined, project: public_project, author: project_owner) } - - it 'includes all events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::ALL) - events = described_class.new(current_user, project_owner, event_filter, params).execute - - expect(events).to include(private_event, internal_event, public_event) - expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) - expect(events.size).to eq(10) - end + context 'filter activity events' do + let_it_be(:push_event) { create(:push_event, project: public_project, author: project_owner) } + let_it_be(:merge_event) { create(:event, :merged, project: public_project, author: project_owner) } + let_it_be(:issue_event) { create(:event, :closed, project: public_project, target: issue, author: project_owner) } + let_it_be(:comment_event) { create(:event, :commented, project: public_project, author: project_owner) } + let_it_be(:wiki_event) { create(:wiki_page_event, project: public_project, author: project_owner) } + let_it_be(:design_event) { create(:design_event, project: public_project, author: project_owner) } + let_it_be(:team_event) { create(:event, :joined, project: public_project, author: project_owner) } + + it 'includes all events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::ALL) + events = described_class.new(current_user, project_owner, event_filter, params).execute + + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) + expect(events.size).to eq(10) + end - it 'only includes push events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::PUSH) - events = described_class.new(current_user, project_owner, event_filter, params).execute + context 'when unknown filter is given' do + it 'includes returns all events', :aggregate_failures do + event_filter = EventFilter.new('unknown') + allow(event_filter).to receive(:filter).and_return('unknown') - expect(events).to include(push_event) - expect(events.size).to eq(1) - end + events = described_class.new(current_user, [project_owner], event_filter, params).execute - it 'only includes merge events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::MERGED) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(private_event, internal_event, public_event) + expect(events).to include(push_event, merge_event, issue_event, comment_event, wiki_event, design_event, team_event) + expect(events.size).to eq(10) + end + end - expect(events).to include(merge_event) - expect(events.size).to eq(1) - end + it 'only includes push events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::PUSH) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes issue events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::ISSUE) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(push_event) + expect(events.size).to eq(1) + end - expect(events).to include(issue_event) - expect(events.size).to eq(1) - end + it 'only includes merge events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::MERGED) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes comments events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::COMMENTS) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(merge_event) + expect(events.size).to eq(1) + end - expect(events).to include(comment_event) - expect(events.size).to eq(1) - end + it 'only includes issue events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::ISSUE) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes wiki events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::WIKI) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(issue_event) + expect(events.size).to eq(1) + end - expect(events).to include(wiki_event) - expect(events.size).to eq(1) - end + it 'only includes comments events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::COMMENTS) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes design events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::DESIGNS) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(comment_event) + expect(events.size).to eq(1) + end - expect(events).to include(design_event) - expect(events.size).to eq(1) - end + it 'only includes wiki events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::WIKI) + events = described_class.new(current_user, project_owner, event_filter, params).execute - it 'only includes team events', :aggregate_failures do - event_filter = EventFilter.new(EventFilter::TEAM) - events = described_class.new(current_user, project_owner, event_filter, params).execute + expect(events).to include(wiki_event) + expect(events.size).to eq(1) + end - expect(events).to include(private_event, internal_event, public_event, team_event) - expect(events.size).to eq(4) - end - end + it 'only includes design events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::DESIGNS) + events = described_class.new(current_user, project_owner, event_filter, params).execute - describe 'issue activity events' do - let(:issue) { create(:issue, project: public_project) } - let(:note) { create(:note_on_issue, noteable: issue, project: public_project) } - let!(:event_a) { create(:event, :commented, target: note, author: project_owner) } - let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) } + expect(events).to include(design_event) + expect(events.size).to eq(1) + end - it 'includes all issue related events', :aggregate_failures do - events = finder.execute + it 'only includes team events', :aggregate_failures do + event_filter = EventFilter.new(EventFilter::TEAM) + events = described_class.new(current_user, project_owner, event_filter, params).execute - expect(events).to include(event_a) - expect(events).to include(event_b) + expect(events).to include(private_event, internal_event, public_event, team_event) + expect(events.size).to eq(4) + end end - end - context 'limits' do - before do - stub_const("#{described_class}::DEFAULT_LIMIT", 1) - stub_const("#{described_class}::MAX_LIMIT", 3) - end + describe 'issue activity events' do + let(:issue) { create(:issue, project: public_project) } + let(:note) { create(:note_on_issue, noteable: issue, project: public_project) } + let!(:event_a) { create(:event, :commented, target: note, author: project_owner) } + let!(:event_b) { create(:event, :closed, target: issue, author: project_owner) } - context 'when limit is not set' do - it 'returns events limited to DEFAULT_LIMIT' do - expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT) + it 'includes all issue related events', :aggregate_failures do + events = finder.execute + + expect(events).to include(event_a) + expect(events).to include(event_b) end end - context 'when limit is set' do - let(:limit) { 2 } + context 'limits' do + before do + stub_const("#{described_class}::DEFAULT_LIMIT", 1) + stub_const("#{described_class}::MAX_LIMIT", 3) + end - it 'returns events limited to specified limit' do - expect(finder.execute.size).to eq(limit) + context 'when limit is not set' do + it 'returns events limited to DEFAULT_LIMIT' do + expect(finder.execute.size).to eq(described_class::DEFAULT_LIMIT) + end end - end - context 'when limit is set to a number that exceeds maximum limit' do - let(:limit) { 4 } + context 'when limit is set' do + let(:limit) { 2 } - before do - create(:event, project: public_project, author: project_owner) + it 'returns events limited to specified limit' do + expect(finder.execute.size).to eq(limit) + end end - it 'returns events limited to MAX_LIMIT' do - expect(finder.execute.size).to eq(described_class::MAX_LIMIT) + context 'when limit is set to a number that exceeds maximum limit' do + let(:limit) { 4 } + + before do + create(:event, project: public_project, author: project_owner) + end + + it 'returns events limited to MAX_LIMIT' do + expect(finder.execute.size).to eq(described_class::MAX_LIMIT) + end end end end end + + context 'when the optimized_followed_users_queries FF is on' do + before do + stub_feature_flags(optimized_followed_users_queries: true) + end + + it_behaves_like 'UserRecentEventsFinder examples' + end + + context 'when the optimized_followed_users_queries FF is off' do + before do + stub_feature_flags(optimized_followed_users_queries: false) + end + + it_behaves_like 'UserRecentEventsFinder examples' + end end diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index fab48cf3178..271dce44db7 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -6,13 +6,15 @@ RSpec.describe UsersFinder do describe '#execute' do include_context 'UsersFinder#execute filter by project context' + let_it_be(:project_bot) { create(:user, :project_bot) } + context 'with a normal user' do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } - it 'returns all users' do + it 'returns searchable users' do users = described_class.new(user).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'filters by username' do @@ -34,9 +36,9 @@ RSpec.describe UsersFinder do end it 'filters by search' do - users = described_class.new(user, search: 'orando').execute + users = described_class.new(user, search: 'ohndo').execute - expect(users).to contain_exactly(blocked_user) + expect(users).to contain_exactly(normal_user) end it 'does not filter by private emails search' do @@ -45,18 +47,6 @@ RSpec.describe UsersFinder do expect(users).to be_empty end - it 'filters by blocked users' do - users = described_class.new(user, blocked: true).execute - - expect(users).to contain_exactly(blocked_user) - end - - it 'filters by active users' do - users = described_class.new(user, active: true).execute - - expect(users).to contain_exactly(user, normal_user, external_user, omniauth_user, admin_user) - end - it 'filters by external users' do users = described_class.new(user, external: true).execute @@ -66,7 +56,7 @@ RSpec.describe UsersFinder do it 'filters by non external users' do users = described_class.new(user, non_external: true).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'filters by created_at' do @@ -83,7 +73,7 @@ RSpec.describe UsersFinder do it 'filters by non internal users' do users = described_class.new(user, non_internal: true).execute - expect(users).to contain_exactly(user, normal_user, external_user, blocked_user, omniauth_user, admin_user) + expect(users).to contain_exactly(user, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot) end it 'does not filter by custom attributes' do @@ -92,23 +82,23 @@ RSpec.describe UsersFinder do custom_attributes: { foo: 'bar' } ).execute - expect(users).to contain_exactly(user, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(user, normal_user, external_user, unconfirmed_user, omniauth_user, internal_user, admin_user, project_bot) end it 'orders returned results' do users = described_class.new(user, sort: 'id_asc').execute - expect(users).to eq([normal_user, admin_user, blocked_user, external_user, omniauth_user, internal_user, user]) + expect(users).to eq([normal_user, admin_user, external_user, unconfirmed_user, omniauth_user, internal_user, project_bot, user]) end it 'does not filter by admins' do users = described_class.new(user, admins: true).execute - expect(users).to contain_exactly(user, normal_user, external_user, admin_user, blocked_user, omniauth_user, internal_user) + expect(users).to contain_exactly(user, normal_user, external_user, admin_user, unconfirmed_user, omniauth_user, internal_user, project_bot) end end context 'with an admin user', :enable_admin_mode do - let(:admin) { create(:admin) } + let_it_be(:admin) { create(:admin) } it 'filters by external users' do users = described_class.new(admin, external: true).execute @@ -119,7 +109,19 @@ RSpec.describe UsersFinder do it 'returns all users' do users = described_class.new(admin).execute - expect(users).to contain_exactly(admin, normal_user, blocked_user, external_user, omniauth_user, internal_user, admin_user) + expect(users).to contain_exactly(admin, normal_user, blocked_user, unconfirmed_user, banned_user, external_user, omniauth_user, internal_user, admin_user, project_bot) + end + + it 'filters by blocked users' do + users = described_class.new(admin, blocked: true).execute + + expect(users).to contain_exactly(blocked_user) + end + + it 'filters by active users' do + users = described_class.new(admin, active: true).execute + + expect(users).to contain_exactly(admin, normal_user, unconfirmed_user, external_user, omniauth_user, admin_user, project_bot) end it 'returns only admins' do diff --git a/spec/fixtures/api/schemas/entities/member_user.json b/spec/fixtures/api/schemas/entities/member_user.json index d42c686bb65..0750e81e115 100644 --- a/spec/fixtures/api/schemas/entities/member_user.json +++ b/spec/fixtures/api/schemas/entities/member_user.json @@ -1,15 +1,28 @@ { "type": "object", - "required": ["id", "name", "username", "avatar_url", "web_url", "blocked", "two_factor_enabled", "show_status"], + "required": [ + "id", + "name", + "username", + "created_at", + "last_activity_on", + "avatar_url", + "web_url", + "blocked", + "two_factor_enabled", + "show_status" + ], "properties": { "id": { "type": "integer" }, "name": { "type": "string" }, "username": { "type": "string" }, + "created_at": { "type": ["string"] }, "avatar_url": { "type": ["string", "null"] }, "web_url": { "type": "string" }, "blocked": { "type": "boolean" }, "two_factor_enabled": { "type": "boolean" }, "availability": { "type": ["string", "null"] }, + "last_activity_on": { "type": ["string", "null"] }, "status": { "type": "object", "required": ["emoji"], diff --git a/spec/fixtures/api/schemas/group_link/group_group_link.json b/spec/fixtures/api/schemas/group_link/group_group_link.json index bfca5c885e3..689679cbc0f 100644 --- a/spec/fixtures/api/schemas/group_link/group_group_link.json +++ b/spec/fixtures/api/schemas/group_link/group_group_link.json @@ -4,12 +4,19 @@ { "$ref": "group_link.json" }, { "required": [ - "can_update", - "can_remove" + "source" ], "properties": { - "can_update": { "type": "boolean" }, - "can_remove": { "type": "boolean" } + "source": { + "type": "object", + "required": ["id", "full_name", "web_url"], + "properties": { + "id": { "type": "integer" }, + "full_name": { "type": "string" }, + "web_url": { "type": "string" } + }, + "additionalProperties": false + } } } ] diff --git a/spec/fixtures/api/schemas/group_link/group_link.json b/spec/fixtures/api/schemas/group_link/group_link.json index 300790728a8..3c2195df11e 100644 --- a/spec/fixtures/api/schemas/group_link/group_link.json +++ b/spec/fixtures/api/schemas/group_link/group_link.json @@ -5,7 +5,10 @@ "created_at", "expires_at", "access_level", - "valid_roles" + "valid_roles", + "can_update", + "can_remove", + "is_direct_member" ], "properties": { "id": { "type": "integer" }, @@ -33,6 +36,9 @@ "web_url": { "type": "string" } }, "additionalProperties": false - } + }, + "can_update": { "type": "boolean" }, + "can_remove": { "type": "boolean" }, + "is_direct_member": { "type": "boolean" } } } diff --git a/spec/fixtures/api/schemas/group_link/project_group_link.json b/spec/fixtures/api/schemas/group_link/project_group_link.json index bfca5c885e3..615c808e5aa 100644 --- a/spec/fixtures/api/schemas/group_link/project_group_link.json +++ b/spec/fixtures/api/schemas/group_link/project_group_link.json @@ -4,12 +4,18 @@ { "$ref": "group_link.json" }, { "required": [ - "can_update", - "can_remove" + "source" ], "properties": { - "can_update": { "type": "boolean" }, - "can_remove": { "type": "boolean" } + "source": { + "type": "object", + "required": ["id", "full_name"], + "properties": { + "id": { "type": "integer" }, + "full_name": { "type": "string" } + }, + "additionalProperties": false + } } } ] diff --git a/spec/fixtures/api/schemas/public_api/v4/agent.json b/spec/fixtures/api/schemas/public_api/v4/agent.json new file mode 100644 index 00000000000..4821d5e0b04 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/agent.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "config_project", + "created_at", + "created_by_user_id" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "config_project": { "$ref": "project_identity.json" }, + "created_at": { "type": "string", "format": "date-time" }, + "created_by_user_id": { "type": "integer" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/agents.json b/spec/fixtures/api/schemas/public_api/v4/agents.json new file mode 100644 index 00000000000..5fe3d7f9481 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/agents.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "agent.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/issue.json b/spec/fixtures/api/schemas/public_api/v4/issue.json index 3173a8ebfb5..90b368b5226 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issue.json +++ b/spec/fixtures/api/schemas/public_api/v4/issue.json @@ -86,6 +86,7 @@ "due_date": { "type": ["string", "null"] }, "confidential": { "type": "boolean" }, "web_url": { "type": "uri" }, + "severity": { "type": "string", "enum": ["UNKNOWN", "LOW", "MEDIUM", "HIGH", "CRITICAL"] }, "time_stats": { "time_estimate": { "type": "integer" }, "total_time_spent": { "type": "integer" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/issue_links.json b/spec/fixtures/api/schemas/public_api/v4/issue_links.json deleted file mode 100644 index d254615dd58..00000000000 --- a/spec/fixtures/api/schemas/public_api/v4/issue_links.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "type": "array", - "items": { - "type": "object", - "properties" : { - "$ref": "./issue_link.json" - } - } -} diff --git a/spec/fixtures/api/schemas/public_api/v4/project_identity.json b/spec/fixtures/api/schemas/public_api/v4/project_identity.json new file mode 100644 index 00000000000..6471dd560c5 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/project_identity.json @@ -0,0 +1,22 @@ +{ + "type": "object", + "required": [ + "id", + "description", + "name", + "name_with_namespace", + "path", + "path_with_namespace", + "created_at" + ], + "properties": { + "id": { "type": "integer" }, + "description": { "type": ["string", "null"] }, + "name": { "type": "string" }, + "name_with_namespace": { "type": "string" }, + "path": { "type": "string" }, + "path_with_namespace": { "type": "string" }, + "created_at": { "type": "string", "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/related_issues.json b/spec/fixtures/api/schemas/public_api/v4/related_issues.json new file mode 100644 index 00000000000..83095ab44c1 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/related_issues.json @@ -0,0 +1,26 @@ +{ + "type": "array", + "items": { + "type": "object", + "allOf": [ + { "$ref": "../../../../../../spec/fixtures/api/schemas/public_api/v4/issue.json" }, + { + "required" : [ + "link_type", + "issue_link_id", + "link_created_at", + "link_updated_at" + ], + "properties" : { + "link_type": { + "type": "string", + "enum": ["relates_to", "blocks", "is_blocked_by"] + }, + "issue_link_id": { "type": "integer" }, + "link_created_at": { "type": "string" }, + "link_updated_at": { "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 465e1193a64..0f9a5ccfa7d 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 @@ -5,6 +5,7 @@ "name": { "type": "string" }, "description": { "type": "string" }, "description_html": { "type": "string" }, + "tag_name": { "type": "string"}, "created_at": { "type": "string", "format": "date-time" }, "released_at": { "type": "string", "format": "date-time" }, "upcoming_release": { "type": "boolean" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json new file mode 100644 index 00000000000..3636c970e83 --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_token.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": [ + "id", + "name", + "user_id", + "active", + "created_at", + "expires_at", + "revoked", + "access_level", + "scopes", + "last_used_at" + ], + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "user_id": { "type": "integer" }, + "active": { "type": "boolean" }, + "created_at": { "type": "string", "format": "date-time" }, + "expires_at": { "type": ["string", "null"], "format": "date" }, + "revoked": { "type": "boolean" }, + "access_level": { "type": "integer" }, + "scopes": { + "type": "array", + "items": { "type": "string" } + }, + "last_used_at": { "type": ["string", "null"], "format": "date-time" } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json new file mode 100644 index 00000000000..1bf013b8bca --- /dev/null +++ b/spec/fixtures/api/schemas/public_api/v4/resource_access_tokens.json @@ -0,0 +1,4 @@ +{ + "type": "array", + "items": { "$ref": "resource_access_token.json" } +} diff --git a/spec/fixtures/api/schemas/public_api/v4/user/admin.json b/spec/fixtures/api/schemas/public_api/v4/user/admin.json index f733914fbf8..8d06e16848f 100644 --- a/spec/fixtures/api/schemas/public_api/v4/user/admin.json +++ b/spec/fixtures/api/schemas/public_api/v4/user/admin.json @@ -26,7 +26,8 @@ "can_create_group", "can_create_project", "two_factor_enabled", - "external" + "external", + "namespace_id" ], "properties": { "$ref": "full.json" diff --git a/spec/fixtures/avatars/avatar1.png b/spec/fixtures/avatars/avatar1.png new file mode 100644 index 00000000000..7e8afb39f17 Binary files /dev/null and b/spec/fixtures/avatars/avatar1.png differ diff --git a/spec/fixtures/avatars/avatar2.png b/spec/fixtures/avatars/avatar2.png new file mode 100644 index 00000000000..462678b1871 Binary files /dev/null and b/spec/fixtures/avatars/avatar2.png differ diff --git a/spec/fixtures/avatars/avatar3.png b/spec/fixtures/avatars/avatar3.png new file mode 100644 index 00000000000..e065f681817 Binary files /dev/null and b/spec/fixtures/avatars/avatar3.png differ diff --git a/spec/fixtures/avatars/avatar4.png b/spec/fixtures/avatars/avatar4.png new file mode 100644 index 00000000000..647ee193cbd Binary files /dev/null and b/spec/fixtures/avatars/avatar4.png differ diff --git a/spec/fixtures/avatars/avatar5.png b/spec/fixtures/avatars/avatar5.png new file mode 100644 index 00000000000..27e973dc5e3 Binary files /dev/null and b/spec/fixtures/avatars/avatar5.png differ diff --git a/spec/fixtures/emails/service_desk_reply_to_and_from.eml b/spec/fixtures/emails/service_desk_reply_to_and_from.eml deleted file mode 100644 index 2545e0d30f8..00000000000 --- a/spec/fixtures/emails/service_desk_reply_to_and_from.eml +++ /dev/null @@ -1,28 +0,0 @@ -Delivered-To: incoming+email-test-project_id-issue-@appmail.adventuretime.ooo -Return-Path: -Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 -Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 -Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; Thu, 13 Jun 2013 14:03:48 -0700 -Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 -Date: Thu, 13 Jun 2013 17:03:48 -0400 -Reply-To: Marceline -From: Finn the Human -Sender: Jake the Dog -To: support@adventuretime.ooo -Delivered-To: support@adventuretime.ooo -Message-ID: -Subject: The message subject! @all -Mime-Version: 1.0 -Content-Type: text/plain; - charset=ISO-8859-1 -Content-Transfer-Encoding: 7bit -X-Sieve: CMU Sieve 2.2 -X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, - 13 Jun 2013 14:03:48 -0700 (PDT) -X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 - -Service desk stuff! - -``` -a = b -``` diff --git a/spec/fixtures/markdown/markdown_golden_master_examples.yml b/spec/fixtures/markdown/markdown_golden_master_examples.yml index 8556811974d..bdd7c13c1a3 100644 --- a/spec/fixtures/markdown/markdown_golden_master_examples.yml +++ b/spec/fixtures/markdown/markdown_golden_master_examples.yml @@ -377,6 +377,34 @@ +- name: diagram_kroki_nomnoml + markdown: |- + ```nomnoml + #stroke: #a86128 + [Decorator pattern| + [Component||+ operation()] + [Client] depends --> [Component] + [Decorator|- next: Component] + [Decorator] decorates -- [ConcreteComponent] + [Component] <:- [Decorator] + [Component] <:- [ConcreteComponent] + ] + ``` + html: |- + + +- name: diagram_plantuml + markdown: |- + ```plantuml + Alice -> Bob: Authentication Request + Bob --> Alice: Authentication Response + + Alice -> Bob: Another authentication Request + Alice <-- Bob: Another authentication Response + ``` + html: |- + + - name: div markdown: |-
plain text
diff --git a/spec/fixtures/security_reports/master/gl-sast-report-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json new file mode 100644 index 00000000000..a80833354ed --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-bandit.json @@ -0,0 +1,43 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864", + "category": "sast", + "message": "Deserialization of Untrusted Data", + "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n", + "cve": "", + "severity": "Critical", + "scanner": { + "id": "bandit", + "name": "Bandit" + }, + "location": { + "file": "app/app.py", + "start_line": 39 + }, + "identifiers": [ + { + "type": "bandit_test_id", + "name": "Bandit Test ID B506", + "value": "B506" + } + ] + } + ], + "scan": { + "scanner": { + "id": "bandit", + "name": "Bandit", + "url": "https://github.com/PyCQA/bandit", + "vendor": { + "name": "GitLab" + }, + "version": "1.7.1" + }, + "type": "sast", + "start_time": "2022-03-11T00:21:49", + "end_time": "2022-03-11T00:21:50", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json new file mode 100644 index 00000000000..42986ea1045 --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-gosec.json @@ -0,0 +1,68 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "2e5656ff30e2e7cc93c36b4845c8a689ddc47fdbccf45d834c67442fbaa89be0", + "category": "sast", + "name": "Key Exchange without Entity Authentication", + "message": "Use of ssh InsecureIgnoreHostKey should be audited", + "description": "The software performs a key exchange with an actor without verifying the identity of that actor.", + "cve": "og.go:8:7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n:CWE-322", + "severity": "Medium", + "confidence": "High", + "raw_source_code_extract": "7: func foo() {\n8: \t_ = ssh.InsecureIgnoreHostKey()\n9: }\n", + "scanner": { + "id": "gosec", + "name": "Gosec" + }, + "location": { + "file": "og.go", + "start_line": 8 + }, + "identifiers": [ + { + "type": "gosec_rule_id", + "name": "Gosec Rule ID G106", + "value": "G106" + }, + { + "type": "CWE", + "name": "CWE-322", + "value": "322", + "url": "https://cwe.mitre.org/data/definitions/322.html" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "og.go", + "line_start": 8, + "line_end": 8, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "og.go|foo[0]:1" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "gosec", + "name": "Gosec", + "url": "https://github.com/securego/gosec", + "vendor": { + "name": "GitLab" + }, + "version": "2.10.0" + }, + "type": "sast", + "start_time": "2022-03-15T20:33:12", + "end_time": "2022-03-15T20:33:17", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json new file mode 100644 index 00000000000..2a60a75366e --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-bandit.json @@ -0,0 +1,71 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "985a5666dcae22adef5ac12f8a8a2dacf9b9b481ae5d87cd0ac1712b0fd64864", + "category": "sast", + "message": "Deserialization of Untrusted Data", + "description": "Avoid using `load()`. `PyYAML.load` can create arbitrary Python\nobjects. A malicious actor could exploit this to run arbitrary\ncode. Use `safe_load()` instead.\n", + "cve": "", + "severity": "Critical", + "scanner": { + "id": "semgrep", + "name": "Semgrep" + }, + "location": { + "file": "app/app.py", + "start_line": 39 + }, + "identifiers": [ + { + "type": "semgrep_id", + "name": "bandit.B506", + "value": "bandit.B506", + "url": "https://semgrep.dev/r/gitlab.bandit.B506" + }, + { + "type": "cwe", + "name": "CWE-502", + "value": "502", + "url": "https://cwe.mitre.org/data/definitions/502.html" + }, + { + "type": "bandit_test_id", + "name": "Bandit Test ID B506", + "value": "B506" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "app/app.py", + "line_start": 39, + "line_end": 39, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "app/app.py|yaml_hammer[0]:13" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "semgrep", + "name": "Semgrep", + "url": "https://github.com/returntocorp/semgrep", + "vendor": { + "name": "GitLab" + }, + "version": "0.82.0" + }, + "type": "sast", + "start_time": "2022-03-11T18:48:16", + "end_time": "2022-03-11T18:48:22", + "status": "success" + } +} diff --git a/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json new file mode 100644 index 00000000000..3d8c65d5823 --- /dev/null +++ b/spec/fixtures/security_reports/master/gl-sast-report-semgrep-for-gosec.json @@ -0,0 +1,70 @@ +{ + "version": "14.0.4", + "vulnerabilities": [ + { + "id": "79f6537b7ec83c7717f5bd1a4f12645916caafefe2e4359148d889855505aa67", + "category": "sast", + "message": "Key Exchange without Entity Authentication", + "description": "Audit the use of ssh.InsecureIgnoreHostKey\n", + "cve": "", + "severity": "Medium", + "scanner": { + "id": "semgrep", + "name": "Semgrep" + }, + "location": { + "file": "og.go", + "start_line": 8 + }, + "identifiers": [ + { + "type": "semgrep_id", + "name": "gosec.G106-1", + "value": "gosec.G106-1" + }, + { + "type": "cwe", + "name": "CWE-322", + "value": "322", + "url": "https://cwe.mitre.org/data/definitions/322.html" + }, + { + "type": "gosec_rule_id", + "name": "Gosec Rule ID G106", + "value": "G106" + } + ], + "tracking": { + "type": "source", + "items": [ + { + "file": "og.go", + "line_start": 8, + "line_end": 8, + "signatures": [ + { + "algorithm": "scope_offset", + "value": "og.go|foo[0]:1" + } + ] + } + ] + } + } + ], + "scan": { + "scanner": { + "id": "semgrep", + "name": "Semgrep", + "url": "https://github.com/returntocorp/semgrep", + "vendor": { + "name": "GitLab" + }, + "version": "0.82.0" + }, + "type": "sast", + "start_time": "2022-03-15T20:36:58", + "end_time": "2022-03-15T20:37:05", + "status": "success" + } +} diff --git a/spec/frontend/__helpers__/matchers/index.js b/spec/frontend/__helpers__/matchers/index.js index 76571bafb06..9b83ced10e1 100644 --- a/spec/frontend/__helpers__/matchers/index.js +++ b/spec/frontend/__helpers__/matchers/index.js @@ -1,3 +1,4 @@ export * from './to_have_sprite_icon'; export * from './to_have_tracking_attributes'; export * from './to_match_interpolated_text'; +export * from './to_validate_json_schema'; diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js new file mode 100644 index 00000000000..ff391f08c55 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema.js @@ -0,0 +1,34 @@ +// NOTE: Make sure to initialize ajv when using this helper + +const getAjvErrorMessage = ({ errors }) => { + return (errors || []).map((error) => { + return `Error with item ${error.instancePath}: ${error.message}`; + }); +}; + +export function toValidateJsonSchema(testData, validator) { + if (!(validator instanceof Function && validator.schema)) { + return { + validator, + message: () => + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + pass: false, + }; + } + + const isValid = validator(testData); + + return { + actual: testData, + message: () => { + if (isValid) { + // We can match, but still fail because we're in a `expect...not.` context + return 'Expected the given data not to pass the schema validation, but found that it was considered valid.'; + } + + const errorMessages = getAjvErrorMessage(validator).join('\n'); + return `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors:\n${errorMessages}`; + }, + pass: isValid, + }; +} diff --git a/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js new file mode 100644 index 00000000000..fd42c710c65 --- /dev/null +++ b/spec/frontend/__helpers__/matchers/to_validate_json_schema_spec.js @@ -0,0 +1,65 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; + +const JSON_SCHEMA = { + type: 'object', + properties: { + fruit: { + type: 'string', + minLength: 3, + }, + }, +}; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(JSON_SCHEMA); + +describe('custom matcher toValidateJsonSchema', () => { + it('throws error if validator is not compiled correctly', () => { + expect(() => { + expect({}).toValidateJsonSchema({}); + }).toThrow( + 'Validator must be a validating function with property "schema", created with `ajv.compile`. See https://ajv.js.org/api.html#ajv-compile-schema-object-data-any-boolean-promise-any.', + ); + }); + + describe('positive assertions', () => { + it.each` + description | input + ${'valid input'} | ${{ fruit: 'apple' }} + `('schema validation passes for $description', ({ input }) => { + expect(input).toValidateJsonSchema(schema); + }); + + it('throws if not matching', () => { + expect(() => expect(null).toValidateJsonSchema(schema)).toThrowError( + `Expected the given data to pass the schema validation, but found that it was considered invalid. Errors: +Error with item : must be object`, + ); + }); + }); + + describe('negative assertions', () => { + it.each` + description | input + ${'no input'} | ${null} + ${'input with invalid type'} | ${'banana'} + ${'input with invalid length'} | ${{ fruit: 'aa' }} + ${'input with invalid type'} | ${{ fruit: 12345 }} + `('schema validation fails for $description', ({ input }) => { + expect(input).not.toValidateJsonSchema(schema); + }); + + it('throws if matching', () => { + expect(() => expect({ fruit: 'apple' }).not.toValidateJsonSchema(schema)).toThrowError( + 'Expected the given data not to pass the schema validation, but found that it was considered valid.', + ); + }); + }); +}); diff --git a/spec/frontend/__helpers__/mock_apollo_helper.js b/spec/frontend/__helpers__/mock_apollo_helper.js index c07a6d8ef85..bae9f33be87 100644 --- a/spec/frontend/__helpers__/mock_apollo_helper.js +++ b/spec/frontend/__helpers__/mock_apollo_helper.js @@ -1,7 +1,7 @@ import { InMemoryCache } from '@apollo/client/core'; import { createMockClient as createMockApolloClient } from 'mock-apollo-client'; import VueApollo from 'vue-apollo'; -import possibleTypes from '~/graphql_shared/possibleTypes.json'; +import possibleTypes from '~/graphql_shared/possible_types.json'; import { typePolicies } from '~/lib/graphql'; export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) { diff --git a/spec/frontend/__helpers__/mock_dom_observer.js b/spec/frontend/__helpers__/mock_dom_observer.js index dd26b594ad9..bc2646be4c2 100644 --- a/spec/frontend/__helpers__/mock_dom_observer.js +++ b/spec/frontend/__helpers__/mock_dom_observer.js @@ -22,14 +22,14 @@ class MockObserver { takeRecords() {} - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_triggerObserve(node, { entry = {}, options = {} } = {}) { if (this.$_hasObserver(node, options)) { this.$_cb([{ target: node, ...entry }]); } } - // eslint-disable-next-line babel/camelcase + // eslint-disable-next-line camelcase $_hasObserver(node, options = {}) { return this.$_observers.some( ([obvNode, obvOptions]) => node === obvNode && isMatch(options, obvOptions), diff --git a/spec/frontend/__helpers__/vuex_action_helper.js b/spec/frontend/__helpers__/vuex_action_helper.js index 68203b544ef..95a811d0385 100644 --- a/spec/frontend/__helpers__/vuex_action_helper.js +++ b/spec/frontend/__helpers__/vuex_action_helper.js @@ -49,6 +49,7 @@ const noop = () => {}; * expectedActions: [], * }) */ + export default ( actionArg, payloadArg, diff --git a/spec/frontend/__helpers__/yaml_transformer.js b/spec/frontend/__helpers__/yaml_transformer.js new file mode 100644 index 00000000000..a23f9b1f715 --- /dev/null +++ b/spec/frontend/__helpers__/yaml_transformer.js @@ -0,0 +1,11 @@ +/* eslint-disable import/no-commonjs */ +const JsYaml = require('js-yaml'); + +// This will transform YAML files to JSON strings +module.exports = { + process: (sourceContent) => { + const jsonContent = JsYaml.load(sourceContent); + const json = JSON.stringify(jsonContent); + return `module.exports = ${json}`; + }, +}; 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 index dd742419d32..36003154b58 100644 --- 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 @@ -8,7 +8,7 @@ exports[`~/access_tokens/components/expires_at_field should render datepicker wi optionaltext="(optional)" > { }); describe('setBaseConfig', () => { - it('commits SET_BASE_CONFIG', (done) => { + it('commits SET_BASE_CONFIG', () => { const options = { contextCommitsPath, mergeRequestIid, projectId }; - testAction( + return testAction( setBaseConfig, options, { @@ -59,62 +59,54 @@ describe('AddContextCommitsModalStoreActions', () => { }, ], [], - done, ); }); }); describe('setTabIndex', () => { - it('commits SET_TABINDEX', (done) => { - testAction( + it('commits SET_TABINDEX', () => { + return testAction( setTabIndex, { tabIndex: 1 }, { tabIndex: 0 }, [{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }], [], - done, ); }); }); describe('setCommits', () => { - it('commits SET_COMMITS', (done) => { - testAction( + it('commits SET_COMMITS', () => { + return testAction( setCommits, { commits: [], silentAddition: false }, { isLoadingCommits: false, commits: [] }, [{ type: types.SET_COMMITS, payload: [] }], [], - done, ); }); - it('commits SET_COMMITS_SILENT', (done) => { - testAction( + it('commits SET_COMMITS_SILENT', () => { + return testAction( setCommits, { commits: [], silentAddition: true }, { isLoadingCommits: true, commits: [] }, [{ type: types.SET_COMMITS_SILENT, payload: [] }], [], - done, ); }); }); describe('createContextCommits', () => { - it('calls API to create context commits', (done) => { + it('calls API to create context commits', async () => { mock.onPost(contextCommitEndpoint).reply(200, {}); - testAction(createContextCommits, { commits: [] }, {}, [], [], done); + await testAction(createContextCommits, { commits: [] }, {}, [], []); - createContextCommits( + await createContextCommits( { state: { projectId, mergeRequestIid }, commit: () => null }, { commits: [] }, - ) - .then(() => { - done(); - }) - .catch(done.fail); + ); }); }); @@ -126,9 +118,9 @@ describe('AddContextCommitsModalStoreActions', () => { ) .reply(200, [dummyCommit]); }); - it('commits FETCH_CONTEXT_COMMITS', (done) => { + it('commits FETCH_CONTEXT_COMMITS', () => { const contextCommit = { ...dummyCommit, isSelected: true }; - testAction( + return testAction( fetchContextCommits, null, { @@ -144,20 +136,18 @@ describe('AddContextCommitsModalStoreActions', () => { { type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } }, { type: 'setSelectedCommits', payload: [contextCommit] }, ], - done, ); }); }); describe('setContextCommits', () => { - it('commits SET_CONTEXT_COMMITS', (done) => { - testAction( + it('commits SET_CONTEXT_COMMITS', () => { + return testAction( setContextCommits, { data: [] }, { contextCommits: [], isLoadingContextCommits: false }, [{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }], [], - done, ); }); }); @@ -168,71 +158,66 @@ describe('AddContextCommitsModalStoreActions', () => { .onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits') .reply(204); }); - it('calls API to remove context commits', (done) => { - testAction( + it('calls API to remove context commits', () => { + return testAction( removeContextCommits, { forceReload: false }, { mergeRequestIid, projectId, toRemoveCommits: [] }, [], [], - done, ); }); }); describe('setSelectedCommits', () => { - it('commits SET_SELECTED_COMMITS', (done) => { - testAction( + it('commits SET_SELECTED_COMMITS', () => { + return testAction( setSelectedCommits, [dummyCommit], { selectedCommits: [] }, [{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }], [], - done, ); }); }); describe('setSearchText', () => { - it('commits SET_SEARCH_TEXT', (done) => { + it('commits SET_SEARCH_TEXT', () => { const searchText = 'Dummy Text'; - testAction( + return testAction( setSearchText, searchText, { searchText: '' }, [{ type: types.SET_SEARCH_TEXT, payload: searchText }], [], - done, ); }); }); describe('setToRemoveCommits', () => { - it('commits SET_TO_REMOVE_COMMITS', (done) => { + it('commits SET_TO_REMOVE_COMMITS', () => { const commitId = 'abcde'; - testAction( + return testAction( setToRemoveCommits, [commitId], { toRemoveCommits: [] }, [{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }], [], - done, ); }); }); describe('resetModalState', () => { - it('commits RESET_MODAL_STATE', (done) => { + it('commits RESET_MODAL_STATE', () => { const commitId = 'abcde'; - testAction( + return testAction( resetModalState, null, { toRemoveCommits: [commitId] }, [{ type: types.RESET_MODAL_STATE }], [], - done, ); }); }); diff --git a/spec/frontend/admin/statistics_panel/store/actions_spec.js b/spec/frontend/admin/statistics_panel/store/actions_spec.js index c7481b664b3..e7cdb5feb6a 100644 --- a/spec/frontend/admin/statistics_panel/store/actions_spec.js +++ b/spec/frontend/admin/statistics_panel/store/actions_spec.js @@ -22,8 +22,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(200, mockStatistics); }); - it('dispatches success with received data', (done) => - testAction( + it('dispatches success with received data', () => { + return testAction( actions.fetchStatistics, null, state, @@ -37,8 +37,8 @@ describe('Admin statistics panel actions', () => { ), }, ], - done, - )); + ); + }); }); describe('error', () => { @@ -46,8 +46,8 @@ describe('Admin statistics panel actions', () => { mock.onGet(/api\/(.*)\/application\/statistics/).replyOnce(500); }); - it('dispatches error', (done) => - testAction( + it('dispatches error', () => { + return testAction( actions.fetchStatistics, null, state, @@ -61,26 +61,26 @@ describe('Admin statistics panel actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, - )); + ); + }); }); }); describe('requestStatistic', () => { - it('should commit the request mutation', (done) => - testAction( + it('should commit the request mutation', () => { + return testAction( actions.requestStatistics, null, state, [{ type: types.REQUEST_STATISTICS }], [], - done, - )); + ); + }); }); describe('receiveStatisticsSuccess', () => { - it('should commit received data', (done) => - testAction( + it('should commit received data', () => { + return testAction( actions.receiveStatisticsSuccess, mockStatistics, state, @@ -91,13 +91,13 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, - )); + ); + }); }); describe('receiveStatisticsError', () => { - it('should commit error', (done) => { - testAction( + it('should commit error', () => { + return testAction( actions.receiveStatisticsError, 500, state, @@ -108,7 +108,6 @@ describe('Admin statistics panel actions', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/admin/topics/components/remove_avatar_spec.js b/spec/frontend/admin/topics/components/remove_avatar_spec.js index d4656f0a199..97d257c682c 100644 --- a/spec/frontend/admin/topics/components/remove_avatar_spec.js +++ b/spec/frontend/admin/topics/components/remove_avatar_spec.js @@ -1,10 +1,11 @@ -import { GlButton, GlModal } from '@gitlab/ui'; +import { GlButton, GlModal, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import RemoveAvatar from '~/admin/topics/components/remove_avatar.vue'; const modalID = 'fake-id'; const path = 'topic/path/1'; +const name = 'Topic 1'; jest.mock('lodash/uniqueId', () => () => 'fake-id'); jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); @@ -16,10 +17,14 @@ describe('RemoveAvatar', () => { wrapper = shallowMount(RemoveAvatar, { provide: { path, + name, }, directives: { GlModal: createMockDirective(), }, + stubs: { + GlSprintf, + }, }); }; @@ -55,8 +60,8 @@ describe('RemoveAvatar', () => { const modal = findModal(); expect(modal.exists()).toBe(true); - expect(modal.props('title')).toBe('Confirm remove avatar'); - expect(modal.text()).toBe('Avatar will be removed. Are you sure?'); + expect(modal.props('title')).toBe('Remove topic avatar'); + expect(modal.text()).toBe(`Topic avatar for ${name} will be removed. This cannot be undone.`); }); it('contains the correct modal ID', () => { diff --git a/spec/frontend/admin/users/components/actions/actions_spec.js b/spec/frontend/admin/users/components/actions/actions_spec.js index fa485e73999..b758c15a91a 100644 --- a/spec/frontend/admin/users/components/actions/actions_spec.js +++ b/spec/frontend/admin/users/components/actions/actions_spec.js @@ -1,9 +1,9 @@ import { GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { kebabCase } from 'lodash'; import Actions from '~/admin/users/components/actions'; -import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { OBSTACLE_TYPES } from '~/vue_shared/components/user_deletion_obstacles/constants'; import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants'; @@ -14,12 +14,11 @@ describe('Action components', () => { const findDropdownItem = () => wrapper.find(GlDropdownItem); - const initComponent = ({ component, props, stubs = {} } = {}) => { + const initComponent = ({ component, props } = {}) => { wrapper = shallowMount(component, { propsData: { ...props, }, - stubs, }); }; @@ -29,7 +28,7 @@ describe('Action components', () => { }); describe('CONFIRMATION_ACTIONS', () => { - it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', async (action) => { + it.each(CONFIRMATION_ACTIONS)('renders a dropdown item for "%s"', (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -38,20 +37,23 @@ describe('Action components', () => { }, }); - await nextTick(); expect(findDropdownItem().exists()).toBe(true); }); }); describe('DELETE_ACTION_COMPONENTS', () => { + beforeEach(() => { + jest.spyOn(eventHub, '$emit').mockImplementation(); + }); + const userDeletionObstacles = [ { name: 'schedule1', type: OBSTACLE_TYPES.oncallSchedules }, { name: 'policy1', type: OBSTACLE_TYPES.escalationPolicies }, ]; - it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))( - 'renders a dropdown item for "%s"', - async (action, expectedPath) => { + it.each(DELETE_ACTIONS)( + 'renders a dropdown item that opens the delete user modal when clicked for "%s"', + async (action) => { initComponent({ component: Actions[capitalizeFirstCharacter(action)], props: { @@ -59,21 +61,19 @@ describe('Action components', () => { paths, userDeletionObstacles, }, - stubs: { SharedDeleteAction }, }); - await nextTick(); - const sharedAction = wrapper.find(SharedDeleteAction); + await findDropdownItem().vm.$emit('click'); - expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block); - expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath); - expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action)); - expect(sharedAction.attributes('data-username')).toBe('John Doe'); - expect(sharedAction.attributes('data-user-deletion-obstacles')).toBe( - JSON.stringify(userDeletionObstacles), + expect(eventHub.$emit).toHaveBeenCalledWith( + EVENT_OPEN_DELETE_USER_MODAL, + expect.objectContaining({ + username: 'John Doe', + blockPath: paths.block, + deletePath: paths[action], + userDeletionObstacles, + }), ); - - expect(findDropdownItem().exists()).toBe(true); }, ); }); diff --git a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap index 7a17ef2cc6c..265569ac0e3 100644 --- a/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/admin/users/components/modals/__snapshots__/delete_user_modal_spec.js.snap @@ -1,160 +1,28 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`User Operation confirmation modal renders modal with form included 1`] = ` -
-

- -

- - + -

- -

- -
- - - - - - - - Cancel - - - - - secondaryAction - - - - - action - -
-`; - -exports[`User Operation confirmation modal when user's name has leading and trailing whitespace displays user's name without whitespace 1`] = ` -
-

- content -

- - -

- To confirm, type - - John Smith - -

- -
- - - - - - - - Cancel - - - - - secondaryAction - - - - - action - -
+ + `; diff --git a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js index f875cd24ee1..09a345ac826 100644 --- a/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js +++ b/spec/frontend/admin/users/components/modals/delete_user_modal_spec.js @@ -1,6 +1,8 @@ import { GlButton, GlFormInput, GlSprintf } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import eventHub, { + EVENT_OPEN_DELETE_USER_MODAL, +} from '~/admin/users/components/modals/delete_user_modal_event_hub'; import DeleteUserModal from '~/admin/users/components/modals/delete_user_modal.vue'; import UserDeletionObstaclesList from '~/vue_shared/components/user_deletion_obstacles/user_deletion_obstacles_list.vue'; import ModalStub from './stubs/modal_stub'; @@ -9,7 +11,7 @@ const TEST_DELETE_USER_URL = 'delete-url'; const TEST_BLOCK_USER_URL = 'block-url'; const TEST_CSRF = 'csrf'; -describe('User Operation confirmation modal', () => { +describe('Delete user modal', () => { let wrapper; let formSubmitSpy; @@ -27,28 +29,36 @@ describe('User Operation confirmation modal', () => { const getMethodParam = () => new FormData(findForm().element).get('_method'); const getFormAction = () => findForm().attributes('action'); const findUserDeletionObstaclesList = () => wrapper.findComponent(UserDeletionObstaclesList); + const findMessageUsername = () => wrapper.findByTestId('message-username'); + const findConfirmUsername = () => wrapper.findByTestId('confirm-username'); + const emitOpenModalEvent = (modalData) => { + return eventHub.$emit(EVENT_OPEN_DELETE_USER_MODAL, modalData); + }; const setUsername = (username) => { - findUsernameInput().vm.$emit('input', username); + return findUsernameInput().vm.$emit('input', username); }; const username = 'username'; const badUsername = 'bad_username'; - const userDeletionObstacles = '["schedule1", "policy1"]'; + const userDeletionObstacles = ['schedule1', 'policy1']; + + const mockModalData = { + username, + blockPath: TEST_BLOCK_USER_URL, + deletePath: TEST_DELETE_USER_URL, + userDeletionObstacles, + i18n: { + title: 'Modal for %{username}', + primaryButtonLabel: 'Delete user', + messageBody: 'Delete %{username} or rather %{strongStart}block user%{strongEnd}?', + }, + }; - const createComponent = (props = {}, stubs = {}) => { - wrapper = shallowMount(DeleteUserModal, { + const createComponent = (stubs = {}) => { + wrapper = shallowMountExtended(DeleteUserModal, { propsData: { - username, - title: 'title', - content: 'content', - action: 'action', - secondaryAction: 'secondaryAction', - deleteUserUrl: TEST_DELETE_USER_URL, - blockUserUrl: TEST_BLOCK_USER_URL, csrfToken: TEST_CSRF, - userDeletionObstacles, - ...props, }, stubs: { GlModal: ModalStub, @@ -68,7 +78,7 @@ describe('User Operation confirmation modal', () => { it('renders modal with form included', () => { createComponent(); - expect(wrapper.element).toMatchSnapshot(); + expect(findForm().element).toMatchSnapshot(); }); describe('on created', () => { @@ -83,11 +93,11 @@ describe('User Operation confirmation modal', () => { }); describe('with incorrect username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(badUsername); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(badUsername); }); it('shows incorrect username', () => { @@ -101,11 +111,11 @@ describe('User Operation confirmation modal', () => { }); describe('with correct username', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); - setUsername(username); + emitOpenModalEvent(mockModalData); - await nextTick(); + return setUsername(username); }); it('shows correct username', () => { @@ -117,11 +127,9 @@ describe('User Operation confirmation modal', () => { expect(findSecondaryButton().attributes('disabled')).toBeFalsy(); }); - describe('when primary action is submitted', () => { - beforeEach(async () => { - findPrimaryButton().vm.$emit('click'); - - await nextTick(); + describe('when primary action is clicked', () => { + beforeEach(() => { + return findPrimaryButton().vm.$emit('click'); }); it('clears the input', () => { @@ -136,11 +144,9 @@ describe('User Operation confirmation modal', () => { }); }); - describe('when secondary action is submitted', () => { - beforeEach(async () => { - findSecondaryButton().vm.$emit('click'); - - await nextTick(); + describe('when secondary action is clicked', () => { + beforeEach(() => { + return findSecondaryButton().vm.$emit('click'); }); it('has correct form attributes and calls submit', () => { @@ -154,22 +160,23 @@ describe('User Operation confirmation modal', () => { describe("when user's name has leading and trailing whitespace", () => { beforeEach(() => { - createComponent( - { - username: ' John Smith ', - }, - { GlSprintf }, - ); + createComponent({ GlSprintf }); + return emitOpenModalEvent({ ...mockModalData, username: ' John Smith ' }); }); it("displays user's name without whitespace", () => { - expect(wrapper.element).toMatchSnapshot(); + expect(findMessageUsername().text()).toBe('John Smith'); + expect(findConfirmUsername().text()).toBe('John Smith'); }); - it("shows enabled buttons when user's name is entered without whitespace", async () => { - setUsername('John Smith'); + it('passes user name without whitespace to the obstacles', () => { + expect(findUserDeletionObstaclesList().props()).toMatchObject({ + userName: 'John Smith', + }); + }); - await nextTick(); + it("shows enabled buttons when user's name is entered without whitespace", async () => { + await setUsername('John Smith'); expect(findPrimaryButton().attributes('disabled')).toBeUndefined(); expect(findSecondaryButton().attributes('disabled')).toBeUndefined(); @@ -177,17 +184,20 @@ describe('User Operation confirmation modal', () => { }); describe('Related user-deletion-obstacles list', () => { - it('does NOT render the list when user has no related obstacles', () => { - createComponent({ userDeletionObstacles: '[]' }); + it('does NOT render the list when user has no related obstacles', async () => { + createComponent(); + await emitOpenModalEvent({ ...mockModalData, userDeletionObstacles: [] }); + expect(findUserDeletionObstaclesList().exists()).toBe(false); }); - it('renders the list when user has related obstalces', () => { + it('renders the list when user has related obstalces', async () => { createComponent(); + await emitOpenModalEvent(mockModalData); const obstacles = findUserDeletionObstaclesList(); expect(obstacles.exists()).toBe(true); - expect(obstacles.props('obstacles')).toEqual(JSON.parse(userDeletionObstacles)); + expect(obstacles.props('obstacles')).toEqual(userDeletionObstacles); }); }); }); diff --git a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js b/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js deleted file mode 100644 index 4786357faa1..00000000000 --- a/spec/frontend/admin/users/components/modals/user_modal_manager_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import UserModalManager from '~/admin/users/components/modals/user_modal_manager.vue'; -import ModalStub from './stubs/modal_stub'; - -describe('Users admin page Modal Manager', () => { - let wrapper; - - const modalConfiguration = { - action1: { - title: 'action1', - content: 'Action Modal 1', - }, - action2: { - title: 'action2', - content: 'Action Modal 2', - }, - }; - - const findModal = () => wrapper.find({ ref: 'modal' }); - - const createComponent = (props = {}) => { - wrapper = mount(UserModalManager, { - propsData: { - selector: '.js-delete-user-modal-button', - modalConfiguration, - csrfToken: 'dummyCSRF', - ...props, - }, - stubs: { - DeleteUserModal: ModalStub, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('render behavior', () => { - it('does not renders modal when initialized', () => { - createComponent(); - expect(findModal().exists()).toBeFalsy(); - }); - - it('throws if action has no proper configuration', () => { - createComponent({ - modalConfiguration: {}, - }); - expect(() => wrapper.vm.show({ glModalAction: 'action1' })).toThrow(); - }); - - it('renders modal with expected props when valid configuration is passed', async () => { - createComponent(); - wrapper.vm.show({ - glModalAction: 'action1', - extraProp: 'extraPropValue', - }); - - await nextTick(); - const modal = findModal(); - expect(modal.exists()).toBeTruthy(); - expect(modal.vm.$attrs.csrfToken).toEqual('dummyCSRF'); - expect(modal.vm.$attrs.extraProp).toEqual('extraPropValue'); - expect(modal.vm.showWasCalled).toBeTruthy(); - }); - }); - - describe('click handling', () => { - let button; - let button2; - - const createButtons = () => { - button = document.createElement('button'); - button2 = document.createElement('button'); - button.setAttribute('class', 'js-delete-user-modal-button'); - button.setAttribute('data-username', 'foo'); - button.setAttribute('data-gl-modal-action', 'action1'); - button.setAttribute('data-block-user-url', '/block'); - button.setAttribute('data-delete-user-url', '/delete'); - document.body.appendChild(button); - document.body.appendChild(button2); - }; - const removeButtons = () => { - button.remove(); - button = null; - button2.remove(); - button2 = null; - }; - - beforeEach(() => { - createButtons(); - createComponent(); - }); - - afterEach(() => { - removeButtons(); - }); - - it('renders the modal when the button is clicked', async () => { - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(true); - }); - - it('does not render the modal when a misconfigured button is clicked', async () => { - button.removeAttribute('data-gl-modal-action'); - button.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - - it('does not render the modal when a button without the selector class is clicked', async () => { - button2.click(); - - await nextTick(); - - expect(findModal().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index 6193233881d..ed185c11732 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -476,9 +476,6 @@ describe('AlertsSettingsWrapper', () => { destroyHttpIntegration(wrapper); expect(destroyIntegrationHandler).toHaveBeenCalled(); - await waitForPromises(); - - expect(findIntegrations()).toHaveLength(3); }); it('displays flash if mutation had a recoverable error', async () => { diff --git a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js index 694dff56632..170af1b5e0c 100644 --- a/spec/frontend/alerts_settings/components/mocks/apollo_mock.js +++ b/spec/frontend/alerts_settings/components/mocks/apollo_mock.js @@ -102,7 +102,7 @@ export const destroyIntegrationResponse = { httpIntegrationDestroy: { errors: [], integration: { - __typename: 'AlertManagementIntegration', + __typename: 'AlertManagementHttpIntegration', id: '37', type: 'HTTP', active: true, diff --git a/spec/frontend/api/alert_management_alerts_api_spec.js b/spec/frontend/api/alert_management_alerts_api_spec.js new file mode 100644 index 00000000000..aac14e64286 --- /dev/null +++ b/spec/frontend/api/alert_management_alerts_api_spec.js @@ -0,0 +1,140 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; +import axios from '~/lib/utils/axios_utils'; + +describe('~/api/alert_management_alerts_api.js', () => { + let mock; + let originalGon; + + const projectId = 1; + const alertIid = 2; + + const imageData = { filePath: 'test', filename: 'hello', id: 5, url: null }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + originalGon = window.gon; + window.gon = { api_version: 'v4' }; + }); + + afterEach(() => { + mock.restore(); + window.gon = originalGon; + }); + + describe('fetchAlertMetricImages', () => { + beforeEach(() => { + jest.spyOn(axios, 'get'); + }); + + it('retrieves metric images from the correct URL and returns them in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + const options = { alertIid, id: projectId }; + + mock.onGet(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi.fetchAlertMetricImages(options).then(({ data }) => { + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + expect(data.data).toEqual(expectedData); + }); + }); + }); + + describe('uploadAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'post'); + }); + + it('uploads a metric image to the correct URL and returns it in the response data', () => { + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`; + const expectedData = [imageData]; + + const file = new File(['zip contents'], 'hello'); + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('file', file); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPost(expectedUrl).reply(201, { data: expectedData }); + + return alertManagementAlertsApi + .uploadAlertMetricImage({ + alertIid, + id: projectId, + file, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, expectedFormData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }); + }); + }); + + describe('updateAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'put'); + }); + + it('updates a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + const url = 'https://www.example.com'; + const urlText = 'Example website'; + + const expectedFormData = new FormData(); + expectedFormData.append('url', url); + expectedFormData.append('url_text', urlText); + + mock.onPut(expectedUrl).reply(200, { data: expectedData }); + + return alertManagementAlertsApi + .updateAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + url, + urlText, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.put).toHaveBeenCalledWith(expectedUrl, expectedFormData); + }); + }); + }); + + describe('deleteAlertMetricImage', () => { + beforeEach(() => { + jest.spyOn(axios, 'delete'); + }); + + it('deletes a metric image to the correct URL and returns it in the response data', () => { + const imageIid = 3; + const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`; + const expectedData = [imageData]; + + mock.onDelete(expectedUrl).reply(204, { data: expectedData }); + + return alertManagementAlertsApi + .deleteAlertMetricImage({ + alertIid, + id: projectId, + imageId: imageIid, + }) + .then(({ data }) => { + expect(data).toEqual({ data: expectedData }); + expect(axios.delete).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index bc3e12d3fc4..85332bf21d8 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -2,6 +2,9 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; +import createFlash from '~/flash'; + +jest.mock('~/flash'); describe('Api', () => { const dummyApiVersion = 'v3000'; @@ -155,66 +158,44 @@ describe('Api', () => { }); describe('group', () => { - it('fetches a group', (done) => { + it('fetches a group', () => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'test', }); - Api.group(groupId, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.group(groupId, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); }); describe('groupMembers', () => { - it('fetches group members', (done) => { + it('fetches group members', () => { const groupId = '54321'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; const expectedData = [{ id: 7 }]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMembers(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('addGroupMembersByUserId', () => { - it('adds an existing User as a new Group Member by User ID', () => { - const groupId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); + return Api.groupMembers(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); }); }); }); - describe('inviteGroupMembersByEmail', () => { + describe('inviteGroupMembers', () => { it('invites a new email address to create a new User and become a Group Member', () => { const groupId = 1; const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`; const params = { email, + userId, access_level: 10, expires_at: undefined, }; @@ -223,14 +204,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => { + return Api.inviteGroupMembers(groupId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('groupMilestones', () => { - it('fetches group milestones', (done) => { + it('fetches group milestones', () => { const groupId = '16'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/milestones`; const expectedData = [ @@ -250,17 +231,14 @@ describe('Api', () => { ]; mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); - Api.groupMilestones(groupId) - .then(({ data }) => { - expect(data).toEqual(expectedData); - }) - .then(done) - .catch(done.fail); + return Api.groupMilestones(groupId).then(({ data }) => { + expect(data).toEqual(expectedData); + }); }); }); describe('groups', () => { - it('fetches groups', (done) => { + it('fetches groups', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; @@ -270,16 +248,18 @@ describe('Api', () => { }, ]); - Api.groups(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groups(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('groupLabels', () => { - it('fetches group labels', (done) => { + it('fetches group labels', () => { const options = { params: { search: 'foo' } }; const expectedGroup = 'gitlab-org'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${expectedGroup}/labels`; @@ -290,18 +270,15 @@ describe('Api', () => { }, ]); - Api.groupLabels(expectedGroup, options) - .then((res) => { - expect(res.length).toBe(1); - expect(res[0].name).toBe('Foo Label'); - }) - .then(done) - .catch(done.fail); + return Api.groupLabels(expectedGroup, options).then((res) => { + expect(res.length).toBe(1); + expect(res[0].name).toBe('Foo Label'); + }); }); }); describe('namespaces', () => { - it('fetches namespaces', (done) => { + it('fetches namespaces', () => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; mock.onGet(expectedUrl).reply(httpStatus.OK, [ @@ -310,16 +287,18 @@ describe('Api', () => { }, ]); - Api.namespaces(query, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.namespaces(query, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('projects', () => { - it('fetches projects with membership when logged in', (done) => { + it('fetches projects with membership when logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -330,14 +309,16 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); - it('fetches projects without membership when not logged in', (done) => { + it('fetches projects without membership when not logged in', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; @@ -347,31 +328,30 @@ describe('Api', () => { }, ]); - Api.projects(query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projects(query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('updateProject', () => { - it('update a project with the given payload', (done) => { + it('update a project with the given payload', () => { const projectPath = 'foo'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' }); - Api.updateProject(projectPath, { foo: 'bar' }) - .then(({ data }) => { - expect(data.foo).toBe('bar'); - done(); - }) - .catch(done.fail); + return Api.updateProject(projectPath, { foo: 'bar' }).then(({ data }) => { + expect(data.foo).toBe('bar'); + }); }); }); describe('projectUsers', () => { - it('fetches all users of a particular project', (done) => { + it('fetches all users of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectPath = 'gitlab-org%2Fgitlab-ce'; @@ -382,13 +362,10 @@ describe('Api', () => { }, ]); - Api.projectUsers('gitlab-org/gitlab-ce', query, options) - .then((response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectUsers('gitlab-org/gitlab-ce', query, options).then((response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + }); }); }); @@ -396,38 +373,32 @@ describe('Api', () => { const projectPath = 'abc'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests`; - it('fetches all merge requests for a project', (done) => { + it('fetches all merge requests for a project', () => { const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; mock.onGet(expectedUrl).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath) - .then(({ data }) => { - expect(data.length).toEqual(2); - expect(data[0].source_branch).toBe('foo'); - expect(data[1].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath).then(({ data }) => { + expect(data.length).toEqual(2); + expect(data[0].source_branch).toBe('foo'); + expect(data[1].source_branch).toBe('bar'); + }); }); - it('fetches merge requests filtered with passed params', (done) => { + it('fetches merge requests filtered with passed params', () => { const params = { source_branch: 'bar', }; const mockData = [{ source_branch: 'bar' }]; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectMergeRequests(projectPath, params) - .then(({ data }) => { - expect(data.length).toEqual(1); - expect(data[0].source_branch).toBe('bar'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequests(projectPath, params).then(({ data }) => { + expect(data.length).toEqual(1); + expect(data[0].source_branch).toBe('bar'); + }); }); }); describe('projectMergeRequest', () => { - it('fetches a merge request', (done) => { + it('fetches a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; @@ -435,17 +406,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequest(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequest(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestChanges', () => { - it('fetches the changes of a merge request', (done) => { + it('fetches the changes of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; @@ -453,17 +421,14 @@ describe('Api', () => { title: 'test', }); - Api.projectMergeRequestChanges(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.title).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestChanges(projectPath, mergeRequestId).then(({ data }) => { + expect(data.title).toBe('test'); + }); }); }); describe('projectMergeRequestVersions', () => { - it('fetches the versions of a merge request', (done) => { + it('fetches the versions of a merge request', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; @@ -473,30 +438,24 @@ describe('Api', () => { }, ]); - Api.projectMergeRequestVersions(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].id).toBe(123); - }) - .then(done) - .catch(done.fail); + return Api.projectMergeRequestVersions(projectPath, mergeRequestId).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].id).toBe(123); + }); }); }); describe('projectRunners', () => { - it('fetches the runners of a project', (done) => { + it('fetches the runners of a project', () => { const projectPath = 7; const params = { scope: 'active' }; const mockData = [{ id: 4 }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); - Api.projectRunners(projectPath, { params }) - .then(({ data }) => { - expect(data).toEqual(mockData); - }) - .then(done) - .catch(done.fail); + return Api.projectRunners(projectPath, { params }).then(({ data }) => { + expect(data).toEqual(mockData); + }); }); }); @@ -525,7 +484,7 @@ describe('Api', () => { }); describe('projectMilestones', () => { - it('fetches project milestones', (done) => { + it('fetches project milestones', () => { const projectId = 1; const options = { state: 'active' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`; @@ -537,13 +496,10 @@ describe('Api', () => { }, ]); - Api.projectMilestones(projectId, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].title).toBe('milestone1'); - }) - .then(done) - .catch(done.fail); + return Api.projectMilestones(projectId, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].title).toBe('milestone1'); + }); }); }); @@ -566,36 +522,15 @@ describe('Api', () => { }); }); - describe('addProjectMembersByUserId', () => { - it('adds an existing User as a new Project Member by User ID', () => { - const projectId = 1; - const expectedUserId = 2; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`; - const params = { - user_id: expectedUserId, - access_level: 10, - expires_at: undefined, - }; - - mock.onPost(expectedUrl).reply(200, { - id: expectedUserId, - state: 'active', - }); - - return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => { - expect(data.id).toBe(expectedUserId); - expect(data.state).toBe('active'); - }); - }); - }); - - describe('inviteProjectMembersByEmail', () => { + describe('inviteProjectMembers', () => { it('invites a new email address to create a new User and become a Project Member', () => { const projectId = 1; - const expectedEmail = 'email@example.com'; + const email = 'email@example.com'; + const userId = '1'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`; const params = { - email: expectedEmail, + email, + userId, access_level: 10, expires_at: undefined, }; @@ -604,14 +539,14 @@ describe('Api', () => { status: 'success', }); - return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => { + return Api.inviteProjectMembers(projectId, params).then(({ data }) => { expect(data.status).toBe('success'); }); }); }); describe('newLabel', () => { - it('creates a new project label', (done) => { + it('creates a new project label', () => { const namespace = 'some namespace'; const project = 'some project'; const labelData = { some: 'data' }; @@ -630,13 +565,15 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, project, labelData, (response) => { - expect(response.name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, project, labelData, (response) => { + expect(response.name).toBe('test'); + resolve(); + }); }); }); - it('creates a new group label', (done) => { + it('creates a new group label', () => { const namespace = 'group/subgroup'; const labelData = { name: 'Foo', color: '#000000' }; const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace); @@ -651,15 +588,17 @@ describe('Api', () => { ]; }); - Api.newLabel(namespace, undefined, labelData, (response) => { - expect(response.name).toBe('Foo'); - done(); + return new Promise((resolve) => { + Api.newLabel(namespace, undefined, labelData, (response) => { + expect(response.name).toBe('Foo'); + resolve(); + }); }); }); }); describe('groupProjects', () => { - it('fetches group projects', (done) => { + it('fetches group projects', () => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; @@ -669,11 +608,40 @@ describe('Api', () => { }, ]); - Api.groupProjects(groupId, query, {}, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.groupProjects(groupId, query, {}, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); + }); + }); + + it('uses flesh on error by default', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + const flashCallback = (callCount) => { + expect(createFlash).toHaveBeenCalledTimes(callCount); + createFlash.mockClear(); + }; + + mock.onGet(expectedUrl).reply(500, null); + + const response = await Api.groupProjects(groupId, query, {}, () => {}).then(() => { + flashCallback(1); }); + expect(response).toBeUndefined(); + }); + + it('NOT uses flesh on error with param useCustomErrorHandler', async () => { + const groupId = '123456'; + const query = 'dummy query'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; + + mock.onGet(expectedUrl).reply(500, null); + const apiCall = Api.groupProjects(groupId, query, {}, () => {}, true); + await expect(apiCall).rejects.toThrow(); }); }); @@ -734,12 +702,14 @@ describe('Api', () => { templateKey, )}`; - it('fetches an issue template', (done) => { + it('fetches an issue template', () => { mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, (_, response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); @@ -747,8 +717,11 @@ describe('Api', () => { it('rejects the Promise', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); - Api.issueTemplate(namespace, project, templateKey, templateType, () => { - expect(mock.history.get).toHaveLength(1); + return new Promise((resolve) => { + Api.issueTemplate(namespace, project, templateKey, templateType, () => { + expect(mock.history.get).toHaveLength(1); + resolve(); + }); }); }); }); @@ -760,19 +733,21 @@ describe('Api', () => { const templateType = 'template type'; const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}`; - it('fetches all templates by type', (done) => { + it('fetches all templates by type', () => { 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(); + return new Promise((resolve) => { + Api.issueTemplates(namespace, project, templateType, (_, 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!'); + resolve(); + }); }); }); @@ -788,34 +763,44 @@ describe('Api', () => { }); describe('projectTemplates', () => { - it('fetches a list of templates', (done) => { + it('fetches a list of templates', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, (response) => { + expect(response).toBe('test'); + resolve(); + }); }); }); }); describe('projectTemplate', () => { - it('fetches a single template', (done) => { + it('fetches a single template', () => { const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); - Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, (response) => { - expect(response).toBe('test'); - done(); + return new Promise((resolve) => { + Api.projectTemplate( + 'gitlab-org/gitlab-ce', + 'licenses', + 'test license', + data, + (response) => { + expect(response).toBe('test'); + resolve(); + }, + ); }); }); }); describe('users', () => { - it('fetches users', (done) => { + it('fetches users', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; @@ -825,68 +810,56 @@ describe('Api', () => { }, ]); - Api.users(query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.users(query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); describe('user', () => { - it('fetches single user', (done) => { + it('fetches single user', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'testuser', }); - Api.user(userId) - .then(({ data }) => { - expect(data.name).toBe('testuser'); - }) - .then(done) - .catch(done.fail); + return Api.user(userId).then(({ data }) => { + expect(data.name).toBe('testuser'); + }); }); }); describe('user counts', () => { - it('fetches single user counts', (done) => { + it('fetches single user counts', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`; mock.onGet(expectedUrl).reply(httpStatus.OK, { merge_requests: 4, }); - Api.userCounts() - .then(({ data }) => { - expect(data.merge_requests).toBe(4); - }) - .then(done) - .catch(done.fail); + return Api.userCounts().then(({ data }) => { + expect(data.merge_requests).toBe(4); + }); }); }); describe('user status', () => { - it('fetches single user status', (done) => { + it('fetches single user status', () => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; mock.onGet(expectedUrl).reply(httpStatus.OK, { message: 'testmessage', }); - Api.userStatus(userId) - .then(({ data }) => { - expect(data.message).toBe('testmessage'); - }) - .then(done) - .catch(done.fail); + return Api.userStatus(userId).then(({ data }) => { + expect(data.message).toBe('testmessage'); + }); }); }); describe('user projects', () => { - it('fetches all projects that belong to a particular user', (done) => { + it('fetches all projects that belong to a particular user', () => { const query = 'dummy query'; const options = { unused: 'option' }; const userId = '123456'; @@ -897,16 +870,18 @@ describe('Api', () => { }, ]); - Api.userProjects(userId, query, options, (response) => { - expect(response.length).toBe(1); - expect(response[0].name).toBe('test'); - done(); + return new Promise((resolve) => { + Api.userProjects(userId, query, options, (response) => { + expect(response.length).toBe(1); + expect(response[0].name).toBe('test'); + resolve(); + }); }); }); }); describe('commitPipelines', () => { - it('fetches pipelines for a given commit', (done) => { + it('fetches pipelines for a given commit', () => { const projectId = 'example/foobar'; const commitSha = 'abc123def'; const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; @@ -916,13 +891,10 @@ describe('Api', () => { }, ]); - Api.commitPipelines(projectId, commitSha) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.commitPipelines(projectId, commitSha).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -947,7 +919,7 @@ describe('Api', () => { }); describe('createBranch', () => { - it('creates new branch', (done) => { + it('creates new branch', () => { const ref = 'main'; const branch = 'new-branch-name'; const dummyProjectPath = 'gitlab-org/gitlab-ce'; @@ -961,18 +933,15 @@ describe('Api', () => { name: branch, }); - Api.createBranch(dummyProjectPath, { ref, branch }) - .then(({ data }) => { - expect(data.name).toBe(branch); - expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); - }) - .then(done) - .catch(done.fail); + return Api.createBranch(dummyProjectPath, { ref, branch }).then(({ data }) => { + expect(data.name).toBe(branch); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, { ref, branch }); + }); }); }); describe('projectForks', () => { - it('gets forked projects', (done) => { + it('gets forked projects', () => { const dummyProjectPath = 'gitlab-org/gitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( dummyProjectPath, @@ -982,20 +951,17 @@ describe('Api', () => { mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']); - Api.projectForks(dummyProjectPath, { visibility: 'private' }) - .then(({ data }) => { - expect(data).toEqual(['fork']); - expect(axios.get).toHaveBeenCalledWith(expectedUrl, { - params: { visibility: 'private' }, - }); - }) - .then(done) - .catch(done.fail); + return Api.projectForks(dummyProjectPath, { visibility: 'private' }).then(({ data }) => { + expect(data).toEqual(['fork']); + expect(axios.get).toHaveBeenCalledWith(expectedUrl, { + params: { visibility: 'private' }, + }); + }); }); }); describe('createContextCommits', () => { - it('creates a new context commit', (done) => { + it('creates a new context commit', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1014,17 +980,16 @@ describe('Api', () => { }, ]); - Api.createContextCommits(projectPath, mergeRequestId, expectedData) - .then(({ data }) => { + return Api.createContextCommits(projectPath, mergeRequestId, expectedData).then( + ({ data }) => { expect(data[0].title).toBe('Dummy commit'); - }) - .then(done) - .catch(done.fail); + }, + ); }); }); describe('allContextCommits', () => { - it('gets all context commits', (done) => { + it('gets all context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; @@ -1035,17 +1000,14 @@ describe('Api', () => { .onGet(expectedUrl) .replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]); - Api.allContextCommits(projectPath, mergeRequestId) - .then(({ data }) => { - expect(data[0].title).toBe('Dummy commit title'); - }) - .then(done) - .catch(done.fail); + return Api.allContextCommits(projectPath, mergeRequestId).then(({ data }) => { + expect(data[0].title).toBe('Dummy commit title'); + }); }); }); describe('removeContextCommits', () => { - it('removes context commits', (done) => { + it('removes context commits', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const commitsData = ['abcdefg']; @@ -1058,12 +1020,9 @@ describe('Api', () => { mock.onDelete(expectedUrl).replyOnce(204); - Api.removeContextCommits(projectPath, mergeRequestId, expectedData) - .then(() => { - expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); - }) - .then(done) - .catch(done.fail); + return Api.removeContextCommits(projectPath, mergeRequestId, expectedData).then(() => { + expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); + }); }); }); @@ -1306,41 +1265,37 @@ describe('Api', () => { }); describe('updateIssue', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const issue = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }) - .then(({ data }) => { - expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + return Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }).then(({ data }) => { + expect(data.assigneeIds).toEqual(expectedArray); + }); }); }); describe('updateMergeRequest', () => { - it('update an issue with the given payload', (done) => { + it('update an issue with the given payload', () => { const projectId = 8; const mergeRequest = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); - Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }) - .then(({ data }) => { + return Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }).then( + ({ data }) => { expect(data.assigneeIds).toEqual(expectedArray); - done(); - }) - .catch(done.fail); + }, + ); }); }); describe('tags', () => { - it('fetches all tags of a particular project', (done) => { + it('fetches all tags of a particular project', () => { const query = 'dummy query'; const options = { unused: 'option' }; const projectId = 8; @@ -1351,13 +1306,10 @@ describe('Api', () => { }, ]); - Api.tags(projectId, query, options) - .then(({ data }) => { - expect(data.length).toBe(1); - expect(data[0].name).toBe('test'); - }) - .then(done) - .catch(done.fail); + return Api.tags(projectId, query, options).then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].name).toBe('test'); + }); }); }); @@ -1641,6 +1593,18 @@ describe('Api', () => { }); }); + describe('dependency proxy cache', () => { + it('schedules the cache list for deletion', async () => { + const groupId = 1; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/dependency_proxy/cache`; + + mock.onDelete(expectedUrl).reply(httpStatus.ACCEPTED); + const { status } = await Api.deleteDependencyProxyCacheList(groupId, {}); + + expect(status).toBe(httpStatus.ACCEPTED); + }); + }); + describe('Feature Flag User List', () => { let expectedUrl; let projectId; @@ -1727,4 +1691,36 @@ describe('Api', () => { }); }); }); + + describe('projectProtectedBranch', () => { + const branchName = 'new-branch-name'; + const dummyProjectId = 5; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${dummyProjectId}/protected_branches/${branchName}`; + + it('returns 404 for non-existing branch', () => { + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.NOT_FOUND, { + message: '404 Not found', + }); + + return Api.projectProtectedBranch(dummyProjectId, branchName).catch((error) => { + expect(error.response.status).toBe(httpStatus.NOT_FOUND); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + + it('returns 200 with branch information', () => { + const expectedObj = { name: branchName }; + + jest.spyOn(axios, 'get'); + + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, expectedObj); + + return Api.projectProtectedBranch(dummyProjectId, branchName).then((data) => { + expect(data).toEqual(expectedObj); + expect(axios.get).toHaveBeenCalledWith(expectedUrl); + }); + }); + }); }); diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index 153d4be56af..31782899ce4 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -36,24 +36,19 @@ describe('U2FAuthenticate', () => { window.u2f = oldu2f; }); - it('falls back to normal 2fa', (done) => { - component - .start() - .then(() => { - expect(component.switchToFallbackUI).toHaveBeenCalled(); - done(); - }) - .catch(done.fail); + it('falls back to normal 2fa', async () => { + await component.start(); + expect(component.switchToFallbackUI).toHaveBeenCalled(); }); }); describe('with u2f available', () => { - beforeEach((done) => { + beforeEach(() => { // bypass automatic form submission within renderAuthenticated jest.spyOn(component, 'renderAuthenticated').mockReturnValue(true); u2fDevice = new MockU2FDevice(); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows authenticating via a U2F device', () => { diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index a814144ac7a..810396aa9fd 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -8,12 +8,12 @@ describe('U2FRegister', () => { let container; let component; - beforeEach((done) => { + beforeEach(() => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); container = $('#js-register-token-2fa'); component = new U2FRegister(container, {}); - component.start().then(done).catch(done.fail); + return component.start(); }); it('allows registering a U2F device', () => { diff --git a/spec/frontend/badges/components/badge_spec.js b/spec/frontend/badges/components/badge_spec.js index 2310fb8bd8e..fe4cf8ce8eb 100644 --- a/spec/frontend/badges/components/badge_spec.js +++ b/spec/frontend/badges/components/badge_spec.js @@ -89,11 +89,9 @@ describe('Badge component', () => { }); describe('behavior', () => { - beforeEach((done) => { + beforeEach(() => { setFixtures('
'); - createComponent({ ...dummyProps }, '#dummy-element') - .then(done) - .catch(done.fail); + return createComponent({ ...dummyProps }, '#dummy-element'); }); it('shows a badge image after loading', () => { diff --git a/spec/frontend/badges/store/actions_spec.js b/spec/frontend/badges/store/actions_spec.js index 75699f24463..02e1b8e65e4 100644 --- a/spec/frontend/badges/store/actions_spec.js +++ b/spec/frontend/badges/store/actions_spec.js @@ -33,41 +33,38 @@ describe('Badges store actions', () => { }); describe('requestNewBadge', () => { - it('commits REQUEST_NEW_BADGE', (done) => { - testAction( + it('commits REQUEST_NEW_BADGE', () => { + return testAction( actions.requestNewBadge, null, state, [{ type: mutationTypes.REQUEST_NEW_BADGE }], [], - done, ); }); }); describe('receiveNewBadge', () => { - it('commits RECEIVE_NEW_BADGE', (done) => { + it('commits RECEIVE_NEW_BADGE', () => { const newBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveNewBadge, newBadge, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE, payload: newBadge }], [], - done, ); }); }); describe('receiveNewBadgeError', () => { - it('commits RECEIVE_NEW_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_NEW_BADGE_ERROR', () => { + return testAction( actions.receiveNewBadgeError, null, state, [{ type: mutationTypes.RECEIVE_NEW_BADGE_ERROR }], [], - done, ); }); }); @@ -87,7 +84,7 @@ describe('Badges store actions', () => { }; }); - it('dispatches requestNewBadge and receiveNewBadge for successful response', (done) => { + it('dispatches requestNewBadge and receiveNewBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -105,16 +102,12 @@ describe('Badges store actions', () => { }); const dummyBadge = transformBackendBadge(dummyResponse); - actions - .addBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); - }) - .then(done) - .catch(done.fail); + + await actions.addBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveNewBadge', dummyBadge]]); }); - it('dispatches requestNewBadge and receiveNewBadgeError for error response', (done) => { + it('dispatches requestNewBadge and receiveNewBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -129,52 +122,43 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .addBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.addBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveNewBadgeError']]); }); }); describe('requestDeleteBadge', () => { - it('commits REQUEST_DELETE_BADGE', (done) => { - testAction( + it('commits REQUEST_DELETE_BADGE', () => { + return testAction( actions.requestDeleteBadge, badgeId, state, [{ type: mutationTypes.REQUEST_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadge', () => { - it('commits RECEIVE_DELETE_BADGE', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE', () => { + return testAction( actions.receiveDeleteBadge, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE, payload: badgeId }], [], - done, ); }); }); describe('receiveDeleteBadgeError', () => { - it('commits RECEIVE_DELETE_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_DELETE_BADGE_ERROR', () => { + return testAction( actions.receiveDeleteBadgeError, badgeId, state, [{ type: mutationTypes.RECEIVE_DELETE_BADGE_ERROR, payload: badgeId }], [], - done, ); }); }); @@ -188,91 +172,76 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadge for successful response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [200, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); - }) - .then(done) - .catch(done.fail); + await actions.deleteBadge({ state, dispatch }, { id: badgeId }); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadge', badgeId]]); }); - it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', (done) => { + it('dispatches requestDeleteBadge and receiveDeleteBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestDeleteBadge', badgeId]]); dispatch.mockClear(); return [500, '']; }); - actions - .deleteBadge({ state, dispatch }, { id: badgeId }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); - }) - .then(done) - .catch(done.fail); + await expect(actions.deleteBadge({ state, dispatch }, { id: badgeId })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveDeleteBadgeError', badgeId]]); }); }); describe('editBadge', () => { - it('commits START_EDITING', (done) => { + it('commits START_EDITING', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.editBadge, dummyBadge, state, [{ type: mutationTypes.START_EDITING, payload: dummyBadge }], [], - done, ); }); }); describe('requestLoadBadges', () => { - it('commits REQUEST_LOAD_BADGES', (done) => { + it('commits REQUEST_LOAD_BADGES', () => { const dummyData = 'this is not real data'; - testAction( + return testAction( actions.requestLoadBadges, dummyData, state, [{ type: mutationTypes.REQUEST_LOAD_BADGES, payload: dummyData }], [], - done, ); }); }); describe('receiveLoadBadges', () => { - it('commits RECEIVE_LOAD_BADGES', (done) => { + it('commits RECEIVE_LOAD_BADGES', () => { const badges = dummyBadges; - testAction( + return testAction( actions.receiveLoadBadges, badges, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES, payload: badges }], [], - done, ); }); }); describe('receiveLoadBadgesError', () => { - it('commits RECEIVE_LOAD_BADGES_ERROR', (done) => { - testAction( + it('commits RECEIVE_LOAD_BADGES_ERROR', () => { + return testAction( actions.receiveLoadBadgesError, null, state, [{ type: mutationTypes.RECEIVE_LOAD_BADGES_ERROR }], [], - done, ); }); }); @@ -286,7 +255,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestLoadBadges and receiveLoadBadges for successful response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadges for successful response', async () => { const dummyData = 'this is just some data'; const dummyReponse = [ createDummyBadgeResponse(), @@ -299,18 +268,13 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => { - const badges = dummyReponse.map(transformBackendBadge); + await actions.loadBadges({ state, dispatch }, dummyData); + const badges = dummyReponse.map(transformBackendBadge); - expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadges', badges]]); }); - it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', (done) => { + it('dispatches requestLoadBadges and receiveLoadBadgesError for error response', async () => { const dummyData = 'this is just some data'; endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestLoadBadges', dummyData]]); @@ -318,53 +282,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .loadBadges({ state, dispatch }, dummyData) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.loadBadges({ state, dispatch }, dummyData)).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveLoadBadgesError']]); }); }); describe('requestRenderedBadge', () => { - it('commits REQUEST_RENDERED_BADGE', (done) => { - testAction( + it('commits REQUEST_RENDERED_BADGE', () => { + return testAction( actions.requestRenderedBadge, null, state, [{ type: mutationTypes.REQUEST_RENDERED_BADGE }], [], - done, ); }); }); describe('receiveRenderedBadge', () => { - it('commits RECEIVE_RENDERED_BADGE', (done) => { + it('commits RECEIVE_RENDERED_BADGE', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveRenderedBadge, dummyBadge, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE, payload: dummyBadge }], [], - done, ); }); }); describe('receiveRenderedBadgeError', () => { - it('commits RECEIVE_RENDERED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_RENDERED_BADGE_ERROR', () => { + return testAction( actions.receiveRenderedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_RENDERED_BADGE_ERROR }], [], - done, ); }); }); @@ -388,56 +343,41 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('returns immediately if imageUrl is empty', (done) => { + it('returns immediately if imageUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.imageUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('returns immediately if linkUrl is empty', (done) => { + it('returns immediately if linkUrl is empty', async () => { jest.spyOn(axios, 'get').mockImplementation(() => {}); badgeInForm.linkUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + await actions.renderBadge({ state, dispatch }); + expect(axios.get).not.toHaveBeenCalled(); }); - it('escapes user input', (done) => { + it('escapes user input', async () => { jest .spyOn(axios, 'get') .mockImplementation(() => Promise.resolve({ data: createDummyBadgeResponse() })); badgeInForm.imageUrl = '&make-sandwich=true'; badgeInForm.linkUrl = ''; - actions - .renderBadge({ state, dispatch }) - .then(() => { - expect(axios.get.mock.calls.length).toBe(1); - const url = axios.get.mock.calls[0][0]; + await actions.renderBadge({ state, dispatch }); + expect(axios.get.mock.calls.length).toBe(1); + const url = axios.get.mock.calls[0][0]; - expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); - expect(url).toMatch( - new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), - ); - expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); - }) - .then(done) - .catch(done.fail); + expect(url).toMatch(new RegExp(`^${dummyEndpointUrl}/render?`)); + expect(url).toMatch( + new RegExp('\\?link_url=%3Cscript%3EI%20am%20dangerous!%3C%2Fscript%3E&'), + ); + expect(url).toMatch(new RegExp('&image_url=%26make-sandwich%3Dtrue$')); }); - it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadge for successful response', async () => { const dummyReponse = createDummyBadgeResponse(); endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); @@ -445,71 +385,57 @@ describe('Badges store actions', () => { return [200, dummyReponse]; }); - actions - .renderBadge({ state, dispatch }) - .then(() => { - const renderedBadge = transformBackendBadge(dummyReponse); + await actions.renderBadge({ state, dispatch }); + const renderedBadge = transformBackendBadge(dummyReponse); - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadge', renderedBadge]]); }); - it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', (done) => { + it('dispatches requestRenderedBadge and receiveRenderedBadgeError for error response', async () => { endpointMock.replyOnce(() => { expect(dispatch.mock.calls).toEqual([['requestRenderedBadge']]); dispatch.mockClear(); return [500, '']; }); - actions - .renderBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.renderBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveRenderedBadgeError']]); }); }); describe('requestUpdatedBadge', () => { - it('commits REQUEST_UPDATED_BADGE', (done) => { - testAction( + it('commits REQUEST_UPDATED_BADGE', () => { + return testAction( actions.requestUpdatedBadge, null, state, [{ type: mutationTypes.REQUEST_UPDATED_BADGE }], [], - done, ); }); }); describe('receiveUpdatedBadge', () => { - it('commits RECEIVE_UPDATED_BADGE', (done) => { + it('commits RECEIVE_UPDATED_BADGE', () => { const updatedBadge = createDummyBadge(); - testAction( + return testAction( actions.receiveUpdatedBadge, updatedBadge, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE, payload: updatedBadge }], [], - done, ); }); }); describe('receiveUpdatedBadgeError', () => { - it('commits RECEIVE_UPDATED_BADGE_ERROR', (done) => { - testAction( + it('commits RECEIVE_UPDATED_BADGE_ERROR', () => { + return testAction( actions.receiveUpdatedBadgeError, null, state, [{ type: mutationTypes.RECEIVE_UPDATED_BADGE_ERROR }], [], - done, ); }); }); @@ -529,7 +455,7 @@ describe('Badges store actions', () => { dispatch = jest.fn(); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadge for successful response', async () => { const dummyResponse = createDummyBadgeResponse(); endpointMock.replyOnce((req) => { @@ -547,16 +473,11 @@ describe('Badges store actions', () => { }); const updatedBadge = transformBackendBadge(dummyResponse); - actions - .saveBadge({ state, dispatch }) - .then(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); - }) - .then(done) - .catch(done.fail); + await actions.saveBadge({ state, dispatch }); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadge', updatedBadge]]); }); - it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', (done) => { + it('dispatches requestUpdatedBadge and receiveUpdatedBadgeError for error response', async () => { endpointMock.replyOnce((req) => { expect(req.data).toBe( JSON.stringify({ @@ -571,53 +492,44 @@ describe('Badges store actions', () => { return [500, '']; }); - actions - .saveBadge({ state, dispatch }) - .then(() => done.fail('Expected Ajax call to fail!')) - .catch(() => { - expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); - }) - .then(done) - .catch(done.fail); + await expect(actions.saveBadge({ state, dispatch })).rejects.toThrow(); + expect(dispatch.mock.calls).toEqual([['receiveUpdatedBadgeError']]); }); }); describe('stopEditing', () => { - it('commits STOP_EDITING', (done) => { - testAction( + it('commits STOP_EDITING', () => { + return testAction( actions.stopEditing, null, state, [{ type: mutationTypes.STOP_EDITING }], [], - done, ); }); }); describe('updateBadgeInForm', () => { - it('commits UPDATE_BADGE_IN_FORM', (done) => { + it('commits UPDATE_BADGE_IN_FORM', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInForm, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_FORM, payload: dummyBadge }], [], - done, ); }); describe('updateBadgeInModal', () => { - it('commits UPDATE_BADGE_IN_MODAL', (done) => { + it('commits UPDATE_BADGE_IN_MODAL', () => { const dummyBadge = createDummyBadge(); - testAction( + return testAction( actions.updateBadgeInModal, dummyBadge, state, [{ type: mutationTypes.UPDATE_BADGE_IN_MODAL, payload: dummyBadge }], [], - done, ); }); }); diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index b0e9e5dd00b..e9535d8cc12 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -29,53 +29,56 @@ describe('Batch comments store actions', () => { }); describe('addDraftToDiscussion', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction( + return testAction( actions.addDraftToDiscussion, { endpoint: TEST_HOST, data: 'test' }, null, [], [], - done, ); }); }); describe('createNewDraft', () => { - it('commits ADD_NEW_DRAFT if no errors returned', (done) => { + it('commits ADD_NEW_DRAFT if no errors returned', () => { res = { id: 1 }; mock.onAny().reply(200, res); - testAction( + return testAction( actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [{ type: 'ADD_NEW_DRAFT', payload: res }], [], - done, ); }); - it('does not commit ADD_NEW_DRAFT if errors returned', (done) => { + it('does not commit ADD_NEW_DRAFT if errors returned', () => { mock.onAny().reply(500); - testAction(actions.createNewDraft, { endpoint: TEST_HOST, data: 'test' }, null, [], [], done); + return testAction( + actions.createNewDraft, + { endpoint: TEST_HOST, data: 'test' }, + null, + [], + [], + ); }); }); @@ -90,7 +93,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits DELETE_DRAFT if no errors returned', (done) => { + it('commits DELETE_DRAFT if no errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -99,16 +102,12 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); - it('does not commit DELETE_DRAFT if errors returned', (done) => { + it('does not commit DELETE_DRAFT if errors returned', () => { const commit = jest.fn(); const context = { getters, @@ -116,13 +115,9 @@ describe('Batch comments store actions', () => { }; mock.onAny().reply(500); - actions - .deleteDraft(context, { id: 1 }) - .then(() => { - expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); - }) - .then(done) - .catch(done.fail); + return actions.deleteDraft(context, { id: 1 }).then(() => { + expect(commit).not.toHaveBeenCalledWith('DELETE_DRAFT', 1); + }); }); }); @@ -137,7 +132,7 @@ describe('Batch comments store actions', () => { }; }); - it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', (done) => { + it('commits SET_BATCH_COMMENTS_DRAFTS with returned data', () => { const commit = jest.fn(); const dispatch = jest.fn(); const context = { @@ -151,14 +146,10 @@ describe('Batch comments store actions', () => { res = { id: 1 }; mock.onAny().reply(200, res); - actions - .fetchDrafts(context) - .then(() => { - expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); - expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); - }) - .then(done) - .catch(done.fail); + return actions.fetchDrafts(context).then(() => { + expect(commit).toHaveBeenCalledWith('SET_BATCH_COMMENTS_DRAFTS', { id: 1 }); + expect(dispatch).toHaveBeenCalledWith('convertToDiscussion', '1', { root: true }); + }); }); }); @@ -177,32 +168,24 @@ describe('Batch comments store actions', () => { rootGetters = { discussionsStructuredByLineCode: 'discussions' }; }); - it('dispatches actions & commits', (done) => { + it('dispatches actions & commits', () => { mock.onAny().reply(200); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_SUCCESS']); - expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); - }) - .then(done) - .catch(done.fail); + expect(dispatch.mock.calls[0]).toEqual(['updateDiscussionsAfterPublish']); + }); }); - it('dispatches error commits', (done) => { + it('dispatches error commits', () => { mock.onAny().reply(500); - actions - .publishReview({ dispatch, commit, getters, rootGetters }) - .then(() => { - expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); - expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); - }) - .then(done) - .catch(done.fail); + return actions.publishReview({ dispatch, commit, getters, rootGetters }).then(() => { + expect(commit.mock.calls[0]).toEqual(['REQUEST_PUBLISH_REVIEW']); + expect(commit.mock.calls[1]).toEqual(['RECEIVE_PUBLISH_REVIEW_ERROR']); + }); }); }); @@ -262,7 +245,7 @@ describe('Batch comments store actions', () => { }); describe('expandAllDiscussions', () => { - it('dispatches expandDiscussion for all drafts', (done) => { + it('dispatches expandDiscussion for all drafts', () => { const state = { drafts: [ { @@ -271,7 +254,7 @@ describe('Batch comments store actions', () => { ], }; - testAction( + return testAction( actions.expandAllDiscussions, null, state, @@ -282,7 +265,6 @@ describe('Batch comments store actions', () => { payload: { discussionId: '1' }, }, ], - done, ); }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index cac1ea67cf5..8842ad636ec 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -77,6 +77,12 @@ describe('gl_emoji', () => { '', `:grey_question:`, ], + [ + 'custom emoji with image fallback', + '', + ':party-parrot:', + ':party-parrot:', + ], ])('%s', (name, markup, withEmojiSupport, withoutEmojiSupport) => { it(`renders correctly with emoji support`, async () => { jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); diff --git a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js b/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js deleted file mode 100644 index d7531d15b9a..00000000000 --- a/spec/frontend/blob/balsamiq/balsamiq_viewer_spec.js +++ /dev/null @@ -1,363 +0,0 @@ -import sqljs from 'sql.js'; -import ClassSpecHelper from 'helpers/class_spec_helper'; -import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; -import axios from '~/lib/utils/axios_utils'; - -jest.mock('sql.js'); - -describe('BalsamiqViewer', () => { - const mockArrayBuffer = new ArrayBuffer(10); - let balsamiqViewer; - let viewer; - - describe('class constructor', () => { - beforeEach(() => { - viewer = {}; - - balsamiqViewer = new BalsamiqViewer(viewer); - }); - - it('should set .viewer', () => { - expect(balsamiqViewer.viewer).toBe(viewer); - }); - }); - - describe('loadFile', () => { - let bv; - const endpoint = 'endpoint'; - const requestSuccess = Promise.resolve({ - data: mockArrayBuffer, - status: 200, - }); - - beforeEach(() => { - viewer = {}; - bv = new BalsamiqViewer(viewer); - }); - - it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockReturnValue(); - - bv.loadFile(endpoint); - - expect(axios.get).toHaveBeenCalledWith( - endpoint, - expect.objectContaining({ - responseType: 'arraybuffer', - }), - ); - }); - - it('should call `renderFile` on request success', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(requestSuccess); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - expect(bv.renderFile).toHaveBeenCalledWith(mockArrayBuffer); - }) - .then(done) - .catch(done.fail); - }); - - it('should not call `renderFile` on request failure', (done) => { - jest.spyOn(axios, 'get').mockReturnValue(Promise.reject()); - jest.spyOn(bv, 'renderFile').mockImplementation(() => {}); - - bv.loadFile(endpoint) - .then(() => { - done.fail('Expected loadFile to throw error!'); - }) - .catch(() => { - expect(bv.renderFile).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - }); - - describe('renderFile', () => { - let container; - let previews; - - beforeEach(() => { - viewer = { - appendChild: jest.fn(), - }; - previews = [document.createElement('ul'), document.createElement('ul')]; - - balsamiqViewer = { - initDatabase: jest.fn(), - getPreviews: jest.fn(), - renderPreview: jest.fn(), - }; - balsamiqViewer.viewer = viewer; - - balsamiqViewer.getPreviews.mockReturnValue(previews); - balsamiqViewer.renderPreview.mockImplementation((preview) => preview); - viewer.appendChild.mockImplementation((containerElement) => { - container = containerElement; - }); - - BalsamiqViewer.prototype.renderFile.call(balsamiqViewer, mockArrayBuffer); - }); - - it('should call .initDatabase', () => { - expect(balsamiqViewer.initDatabase).toHaveBeenCalledWith(mockArrayBuffer); - }); - - it('should call .getPreviews', () => { - expect(balsamiqViewer.getPreviews).toHaveBeenCalled(); - }); - - it('should call .renderPreview for each preview', () => { - const allArgs = balsamiqViewer.renderPreview.mock.calls; - - expect(allArgs.length).toBe(2); - - previews.forEach((preview, i) => { - expect(allArgs[i][0]).toBe(preview); - }); - }); - - it('should set the container HTML', () => { - expect(container.innerHTML).toBe('
      '); - }); - - it('should add inline preview classes', () => { - expect(container.classList[0]).toBe('list-inline'); - expect(container.classList[1]).toBe('previews'); - }); - - it('should call viewer.appendChild', () => { - expect(viewer.appendChild).toHaveBeenCalledWith(container); - }); - }); - - describe('initDatabase', () => { - let uint8Array; - let data; - - beforeEach(() => { - uint8Array = {}; - data = 'data'; - balsamiqViewer = {}; - window.Uint8Array = jest.fn(); - window.Uint8Array.mockReturnValue(uint8Array); - - BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); - }); - - it('should instantiate Uint8Array', () => { - expect(window.Uint8Array).toHaveBeenCalledWith(data); - }); - - it('should call sqljs.Database', () => { - expect(sqljs.Database).toHaveBeenCalledWith(uint8Array); - }); - - it('should set .database', () => { - expect(balsamiqViewer.database).not.toBe(null); - }); - }); - - describe('getPreviews', () => { - let database; - let thumbnails; - let getPreviews; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - thumbnails = [{ values: [0, 1, 2] }]; - - balsamiqViewer = { - database, - }; - - jest - .spyOn(BalsamiqViewer, 'parsePreview') - .mockImplementation((preview) => preview.toString()); - database.exec.mockReturnValue(thumbnails); - - getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith('SELECT * FROM thumbnails'); - }); - - it('should call .parsePreview for each value', () => { - const allArgs = BalsamiqViewer.parsePreview.mock.calls; - - expect(allArgs.length).toBe(3); - - thumbnails[0].values.forEach((value, i) => { - expect(allArgs[i][0]).toBe(value); - }); - }); - - it('should return an array of parsed values', () => { - expect(getPreviews).toEqual(['0', '1', '2']); - }); - }); - - describe('getResource', () => { - let database; - let resourceID; - let resource; - let getResource; - - beforeEach(() => { - database = { - exec: jest.fn(), - }; - resourceID = 4; - resource = ['resource']; - - balsamiqViewer = { - database, - }; - - database.exec.mockReturnValue(resource); - - getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); - }); - - it('should call database.exec', () => { - expect(database.exec).toHaveBeenCalledWith( - `SELECT * FROM resources WHERE id = '${resourceID}'`, - ); - }); - - it('should return the selected resource', () => { - expect(getResource).toBe(resource[0]); - }); - }); - - describe('renderPreview', () => { - let previewElement; - let innerHTML; - let preview; - let renderPreview; - - beforeEach(() => { - innerHTML = 'innerHTML'; - previewElement = { - outerHTML: '

      outerHTML

      ', - classList: { - add: jest.fn(), - }, - }; - preview = {}; - - balsamiqViewer = { - renderTemplate: jest.fn(), - }; - - jest.spyOn(document, 'createElement').mockReturnValue(previewElement); - balsamiqViewer.renderTemplate.mockReturnValue(innerHTML); - - renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); - }); - - it('should call classList.add', () => { - expect(previewElement.classList.add).toHaveBeenCalledWith('preview'); - }); - - it('should call .renderTemplate', () => { - expect(balsamiqViewer.renderTemplate).toHaveBeenCalledWith(preview); - }); - - it('should set .innerHTML', () => { - expect(previewElement.innerHTML).toBe(innerHTML); - }); - - it('should return element', () => { - expect(renderPreview).toBe(previewElement); - }); - }); - - describe('renderTemplate', () => { - let preview; - let name; - let resource; - let template; - let renderTemplate; - - beforeEach(() => { - preview = { resourceID: 1, image: 'image' }; - name = 'name'; - resource = 'resource'; - template = ` -
      -
      name
      -
      - -
      -
      - `; - - balsamiqViewer = { - getResource: jest.fn(), - }; - - jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name); - balsamiqViewer.getResource.mockReturnValue(resource); - - renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); - }); - - it('should call .getResource', () => { - expect(balsamiqViewer.getResource).toHaveBeenCalledWith(preview.resourceID); - }); - - it('should call .parseTitle', () => { - expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); - }); - - it('should return the template string', () => { - expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, '')); - }); - }); - - describe('parsePreview', () => { - let preview; - let parsePreview; - - beforeEach(() => { - preview = ['{}', '{ "id": 1 }']; - - jest.spyOn(JSON, 'parse'); - - parsePreview = BalsamiqViewer.parsePreview(preview); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the parsed JSON', () => { - expect(parsePreview).toEqual(JSON.parse('{ "id": 1 }')); - }); - }); - - describe('parseTitle', () => { - let title; - let parseTitle; - - beforeEach(() => { - title = { values: [['{}', '{}', '{"name":"name"}']] }; - - jest.spyOn(JSON, 'parse'); - - parseTitle = BalsamiqViewer.parseTitle(title); - }); - - ClassSpecHelper.itShouldBeAStaticMethod(BalsamiqViewer, 'parsePreview'); - - it('should return the name value', () => { - expect(parseTitle).toBe('name'); - }); - }); -}); diff --git a/spec/frontend/boards/boards_util_spec.js b/spec/frontend/boards/boards_util_spec.js index d45b6e35a45..ab3cf072357 100644 --- a/spec/frontend/boards/boards_util_spec.js +++ b/spec/frontend/boards/boards_util_spec.js @@ -1,6 +1,12 @@ import { formatIssueInput, filterVariables } from '~/boards/boards_util'; describe('formatIssueInput', () => { + const issueInput = { + labelIds: ['gid://gitlab/GroupLabel/5'], + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + }; + it('correctly merges boardConfig into the issue', () => { const boardConfig = { labels: [ @@ -14,12 +20,6 @@ describe('formatIssueInput', () => { weight: 1, }; - const issueInput = { - labelIds: ['gid://gitlab/GroupLabel/5'], - projectPath: 'gitlab-org/gitlab-test', - id: 'gid://gitlab/Issue/11', - }; - const result = formatIssueInput(issueInput, boardConfig); expect(result).toEqual({ projectPath: 'gitlab-org/gitlab-test', @@ -27,8 +27,26 @@ describe('formatIssueInput', () => { labelIds: ['gid://gitlab/GroupLabel/5', 'gid://gitlab/GroupLabel/44'], assigneeIds: ['gid://gitlab/User/55'], milestoneId: 'gid://gitlab/Milestone/66', + weight: 1, }); }); + + it('does not add weight to input if weight is NONE', () => { + const boardConfig = { + weight: -2, // NO_WEIGHT + }; + + const result = formatIssueInput(issueInput, boardConfig); + const expected = { + projectPath: 'gitlab-org/gitlab-test', + id: 'gid://gitlab/Issue/11', + labelIds: ['gid://gitlab/GroupLabel/5'], + assigneeIds: [], + milestoneId: undefined, + }; + + expect(result).toEqual(expected); + }); }); describe('filterVariables', () => { diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 85ba703a6ee..731578e15a3 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -124,7 +124,7 @@ describe('BoardFilteredSearch', () => { { type: 'milestone', value: { data: 'New Milestone', operator: '=' } }, { type: 'type', value: { data: 'INCIDENT', operator: '=' } }, { type: 'weight', value: { data: '2', operator: '=' } }, - { type: 'iteration', value: { data: '3341', operator: '=' } }, + { type: 'iteration', value: { data: 'Any&3', operator: '=' } }, { type: 'release', value: { data: 'v1.0.0', operator: '=' } }, ]; jest.spyOn(urlUtility, 'updateHistory'); @@ -134,7 +134,7 @@ describe('BoardFilteredSearch', () => { title: '', replace: true, url: - 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=3341&types=INCIDENT&weight=2&release_tag=v1.0.0', + 'http://test.host/?author_username=root&label_name[]=label&label_name[]=label%262&assignee_username=root&milestone_title=New%20Milestone&iteration_id=Any&iteration_cadence_id=3&types=INCIDENT&weight=2&release_tag=v1.0.0', }); }); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index c976ba7525b..6a659623b53 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -62,7 +62,7 @@ describe('BoardForm', () => { }; }, provide: { - rootPath: 'root', + boardBaseUrl: 'root', }, mocks: { $apollo: { diff --git a/spec/frontend/boards/components/board_top_bar_spec.js b/spec/frontend/boards/components/board_top_bar_spec.js new file mode 100644 index 00000000000..997768a0cc7 --- /dev/null +++ b/spec/frontend/boards/components/board_top_bar_spec.js @@ -0,0 +1,88 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import Vuex from 'vuex'; + +import BoardTopBar from '~/boards/components/board_top_bar.vue'; +import BoardAddNewColumnTrigger from '~/boards/components/board_add_new_column_trigger.vue'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; +import ConfigToggle from '~/boards/components/config_toggle.vue'; +import IssueBoardFilteredSearch from '~/boards/components/issue_board_filtered_search.vue'; +import NewBoardButton from '~/boards/components/new_board_button.vue'; +import ToggleFocus from '~/boards/components/toggle_focus.vue'; + +describe('BoardTopBar', () => { + let wrapper; + + Vue.use(Vuex); + + const createStore = ({ mockGetters = {} } = {}) => { + return new Vuex.Store({ + state: {}, + getters: { + isEpicBoard: () => false, + ...mockGetters, + }, + }); + }; + + const createComponent = ({ provide = {}, mockGetters = {} } = {}) => { + const store = createStore({ mockGetters }); + wrapper = shallowMount(BoardTopBar, { + store, + provide: { + swimlanesFeatureAvailable: false, + canAdminList: false, + isSignedIn: false, + fullPath: 'gitlab-org', + boardType: 'group', + releasesFetchPath: '/releases', + ...provide, + }, + stubs: { IssueBoardFilteredSearch }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('base template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders BoardsSelector component', () => { + expect(wrapper.findComponent(BoardsSelector).exists()).toBe(true); + }); + + it('renders IssueBoardFilteredSearch component', () => { + expect(wrapper.findComponent(IssueBoardFilteredSearch).exists()).toBe(true); + }); + + it('renders NewBoardButton component', () => { + expect(wrapper.findComponent(NewBoardButton).exists()).toBe(true); + }); + + it('renders ConfigToggle component', () => { + expect(wrapper.findComponent(ConfigToggle).exists()).toBe(true); + }); + + it('renders ToggleFocus component', () => { + expect(wrapper.findComponent(ToggleFocus).exists()).toBe(true); + }); + + it('does not render BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(false); + }); + }); + + describe('when user can admin list', () => { + beforeEach(() => { + createComponent({ provide: { canAdminList: true } }); + }); + + it('renders BoardAddNewColumnTrigger component', () => { + expect(wrapper.findComponent(BoardAddNewColumnTrigger).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index 0c044deb78c..f60d04af4fc 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,5 +1,4 @@ import { GlDropdown, GlLoadingIcon, GlDropdownSectionHeader } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; @@ -14,6 +13,7 @@ import groupRecentBoardsQuery from '~/boards/graphql/group_recent_boards.query.g import projectRecentBoardsQuery from '~/boards/graphql/project_recent_boards.query.graphql'; import defaultStore from '~/boards/stores'; import createMockApollo from 'helpers/mock_apollo_helper'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockGroupBoardResponse, mockProjectBoardResponse, @@ -60,7 +60,7 @@ describe('BoardsSelector', () => { searchBoxInput.trigger('input'); }; - const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); + const getDropdownItems = () => wrapper.findAllByTestId('dropdown-item'); const getDropdownHeaders = () => wrapper.findAllComponents(GlDropdownSectionHeader); const getLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findDropdown = () => wrapper.findComponent(GlDropdown); @@ -100,11 +100,15 @@ describe('BoardsSelector', () => { [groupRecentBoardsQuery, groupRecentBoardsQueryHandlerSuccess], ]); - wrapper = mount(BoardsSelector, { + wrapper = mountExtended(BoardsSelector, { store, apolloProvider: fakeApollo, propsData: { throttleDuration, + }, + attachTo: document.body, + provide: { + fullPath: '', boardBaseUrl: `${TEST_HOST}/board/base/url`, hasMissingBoards: false, canAdminBoard: true, @@ -112,10 +116,6 @@ describe('BoardsSelector', () => { scopedIssueBoardFeatureEnabled: true, weights: [], }, - attachTo: document.body, - provide: { - fullPath: '', - }, }); }; diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js deleted file mode 100644 index 4b7f491b998..00000000000 --- a/spec/frontend/boards/components/issuable_title_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IssuableTitle from '~/boards/components/issuable_title.vue'; - -describe('IssuableTitle', () => { - let wrapper; - const defaultProps = { - title: 'One', - refPath: 'path', - }; - const createComponent = () => { - wrapper = shallowMount(IssuableTitle, { - propsData: { ...defaultProps }, - }); - }; - const findIssueContent = () => wrapper.find('[data-testid="issue-title"]'); - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders a title of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('One'); - }); - - it('renders a referencePath of an issue in the sidebar', () => { - expect(findIssueContent().text()).toContain('path'); - }); -}); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index 76e8b84d8ef..e4a6a2b8b76 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -14,10 +14,11 @@ describe('IssueBoardFilter', () => { const createComponent = ({ isSignedIn = false } = {}) => { wrapper = shallowMount(IssueBoardFilteredSpec, { - propsData: { fullPath: 'gitlab-org', boardType: 'group' }, provide: { isSignedIn, releasesFetchPath: '/releases', + fullPath: 'gitlab-org', + boardType: 'group', }, }); }; diff --git a/spec/frontend/boards/components/issue_time_estimate_spec.js b/spec/frontend/boards/components/issue_time_estimate_spec.js index 635964b6b4a..948a7a20f7f 100644 --- a/spec/frontend/boards/components/issue_time_estimate_spec.js +++ b/spec/frontend/boards/components/issue_time_estimate_spec.js @@ -5,6 +5,8 @@ import IssueTimeEstimate from '~/boards/components/issue_time_estimate.vue'; describe('Issue Time Estimate component', () => { let wrapper; + const findIssueTimeEstimate = () => wrapper.find('[data-testid="issue-time-estimate"]'); + afterEach(() => { wrapper.destroy(); }); @@ -26,7 +28,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('2 weeks 3 days 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('2 weeks 3 days 1 minute'); }); it('prevents tooltip xss', async () => { @@ -42,7 +44,7 @@ describe('Issue Time Estimate component', () => { expect(alertSpy).not.toHaveBeenCalled(); expect(wrapper.find('time').text().trim()).toEqual('0m'); - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('0m'); + expect(findIssueTimeEstimate().text()).toContain('0m'); }); }); @@ -63,7 +65,7 @@ describe('Issue Time Estimate component', () => { }); it('renders expanded time estimate in tooltip', () => { - expect(wrapper.find('.js-issue-time-estimate').text()).toContain('104 hours 1 minute'); + expect(findIssueTimeEstimate().text()).toContain('104 hours 1 minute'); }); }); }); diff --git a/spec/frontend/boards/components/item_count_spec.js b/spec/frontend/boards/components/item_count_spec.js index 45980c36f1c..06cd3910fc0 100644 --- a/spec/frontend/boards/components/item_count_spec.js +++ b/spec/frontend/boards/components/item_count_spec.js @@ -29,7 +29,7 @@ describe('IssueCount', () => { }); it('does not contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').exists()).toBe(false); + expect(vm.find('.max-issue-size').exists()).toBe(false); }); }); @@ -50,7 +50,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('does not have text-danger class when issueSize is less than maxIssueCount', () => { @@ -75,7 +75,7 @@ describe('IssueCount', () => { }); it('contains maxIssueCount in the template', () => { - expect(vm.find('.js-max-issue-size').text()).toEqual(String(maxIssueCount)); + expect(vm.find('.max-issue-size').text()).toEqual(String(maxIssueCount)); }); it('has text-danger class', () => { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index ad661a31556..eacf9db191e 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -166,31 +166,29 @@ describe('setFilters', () => { }); describe('performSearch', () => { - it('should dispatch setFilters, fetchLists and resetIssues action', (done) => { - testAction( + it('should dispatch setFilters, fetchLists and resetIssues action', () => { + return testAction( actions.performSearch, {}, {}, [], [{ type: 'setFilters', payload: {} }, { type: 'fetchLists' }, { type: 'resetIssues' }], - done, ); }); }); describe('setActiveId', () => { - it('should commit mutation SET_ACTIVE_ID', (done) => { + it('should commit mutation SET_ACTIVE_ID', () => { const state = { activeId: inactiveId, }; - testAction( + return testAction( actions.setActiveId, { id: 1, sidebarType: 'something' }, state, [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }], [], - done, ); }); }); @@ -219,10 +217,10 @@ describe('fetchLists', () => { const formattedLists = formatBoardLists(queryResponse.data.group.board.lists); - it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -233,14 +231,13 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', (done) => { + it('should commit mutations RECEIVE_BOARD_LISTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -250,11 +247,10 @@ describe('fetchLists', () => { }, ], [], - done, ); }); - it('dispatch createList action when backlog list does not exist and is not hidden', (done) => { + it('dispatch createList action when backlog list does not exist and is not hidden', () => { queryResponse = { data: { group: { @@ -269,7 +265,7 @@ describe('fetchLists', () => { }; jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchLists, {}, state, @@ -280,7 +276,6 @@ describe('fetchLists', () => { }, ], [{ type: 'createList', payload: { backlog: true } }], - done, ); }); @@ -951,10 +946,10 @@ describe('fetchItemsForList', () => { }); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -973,14 +968,13 @@ describe('fetchItemsForList', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_ITEMS_FOR_LIST and RECEIVE_ITEMS_FOR_LIST_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(Promise.reject()); - testAction( + return testAction( actions.fetchItemsForList, { listId }, state, @@ -996,7 +990,6 @@ describe('fetchItemsForList', () => { { type: types.RECEIVE_ITEMS_FOR_LIST_FAILURE, payload: listId }, ], [], - done, ); }); }); @@ -1398,8 +1391,8 @@ describe('setAssignees', () => { const node = { username: 'name' }; describe('when succeeds', () => { - it('calls the correct mutation with the correct values', (done) => { - testAction( + it('calls the correct mutation with the correct values', () => { + return testAction( actions.setAssignees, { assignees: [node], iid: '1' }, { commit: () => {} }, @@ -1410,7 +1403,6 @@ describe('setAssignees', () => { }, ], [], - done, ); }); }); @@ -1728,7 +1720,7 @@ describe('setActiveItemSubscribed', () => { projectPath: 'gitlab-org/gitlab-test', }; - it('should commit subscribed status', (done) => { + it('should commit subscribed status', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableSubscription: { @@ -1746,7 +1738,7 @@ describe('setActiveItemSubscribed', () => { value: subscribedState, }; - testAction( + return testAction( actions.setActiveItemSubscribed, input, { ...state, ...getters }, @@ -1757,7 +1749,6 @@ describe('setActiveItemSubscribed', () => { }, ], [], - done, ); }); @@ -1783,7 +1774,7 @@ describe('setActiveItemTitle', () => { projectPath: 'h/b', }; - it('should commit title after setting the issue', (done) => { + it('should commit title after setting the issue', () => { jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ data: { updateIssuableTitle: { @@ -1801,7 +1792,7 @@ describe('setActiveItemTitle', () => { value: testTitle, }; - testAction( + return testAction( actions.setActiveItemTitle, input, { ...state, ...getters }, @@ -1812,7 +1803,6 @@ describe('setActiveItemTitle', () => { }, ], [], - done, ); }); @@ -1829,14 +1819,14 @@ describe('setActiveItemConfidential', () => { const state = { boardItems: { [mockIssue.id]: mockIssue } }; const getters = { activeBoardItem: mockIssue }; - it('set confidential value on board item', (done) => { + it('set confidential value on board item', () => { const payload = { itemId: getters.activeBoardItem.id, prop: 'confidential', value: true, }; - testAction( + return testAction( actions.setActiveItemConfidential, true, { ...state, ...getters }, @@ -1847,7 +1837,6 @@ describe('setActiveItemConfidential', () => { }, ], [], - done, ); }); }); @@ -1876,10 +1865,10 @@ describe('fetchGroupProjects', () => { }, }; - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_SUCCESS on success', () => { jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1894,14 +1883,13 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); - it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', (done) => { + it('should commit mutations REQUEST_GROUP_PROJECTS and RECEIVE_GROUP_PROJECTS_FAILURE on failure', () => { jest.spyOn(gqlClient, 'query').mockRejectedValue(); - testAction( + return testAction( actions.fetchGroupProjects, {}, state, @@ -1915,16 +1903,15 @@ describe('fetchGroupProjects', () => { }, ], [], - done, ); }); }); describe('setSelectedProject', () => { - it('should commit mutation SET_SELECTED_PROJECT', (done) => { + it('should commit mutation SET_SELECTED_PROJECT', () => { const project = mockGroupProjects[0]; - testAction( + return testAction( actions.setSelectedProject, project, {}, @@ -1935,7 +1922,6 @@ describe('setSelectedProject', () => { }, ], [], - done, ); }); }); diff --git a/spec/frontend/captcha/apollo_captcha_link_spec.js b/spec/frontend/captcha/apollo_captcha_link_spec.js index eab52344d1f..cd32e63d00c 100644 --- a/spec/frontend/captcha/apollo_captcha_link_spec.js +++ b/spec/frontend/captcha/apollo_captcha_link_spec.js @@ -95,70 +95,82 @@ describe('apolloCaptchaLink', () => { return { operationName: 'operation', variables: {}, setContext: mockContext }; } - it('successful responses are passed through', (done) => { + it('successful responses are passed through', () => { setupLink(SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('non-spam related errors are passed through', (done) => { + it('non-spam related errors are passed through', () => { setupLink(NON_CAPTCHA_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(NON_CAPTCHA_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); - it('unresolvable spam errors are passed through', (done) => { + it('unresolvable spam errors are passed through', () => { setupLink(SPAM_ERROR_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SPAM_ERROR_RESPONSE); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - expect(mockContext).not.toHaveBeenCalled(); - expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); - done(); + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SPAM_ERROR_RESPONSE); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + expect(mockContext).not.toHaveBeenCalled(); + expect(waitForCaptchaToBeSolved).not.toHaveBeenCalled(); + resolve(); + }); }); }); describe('resolvable spam errors', () => { - it('re-submits request with spam headers if the captcha modal was solved correctly', (done) => { + it('re-submits request with spam headers if the captcha modal was solved correctly', () => { waitForCaptchaToBeSolved.mockResolvedValue(CAPTCHA_RESPONSE); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe((result) => { - expect(result).toEqual(SUCCESS_RESPONSE); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).toHaveBeenCalledWith({ - headers: { - 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, - 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, - }, + return new Promise((resolve) => { + link.request(mockOperation()).subscribe((result) => { + expect(result).toEqual(SUCCESS_RESPONSE); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).toHaveBeenCalledWith({ + headers: { + 'X-GitLab-Captcha-Response': CAPTCHA_RESPONSE, + 'X-GitLab-Spam-Log-Id': SPAM_LOG_ID, + }, + }); + expect(mockLinkImplementation).toHaveBeenCalledTimes(2); + resolve(); }); - expect(mockLinkImplementation).toHaveBeenCalledTimes(2); - done(); }); }); - it('throws error if the captcha modal was not solved correctly', (done) => { + it('throws error if the captcha modal was not solved correctly', () => { const error = new UnsolvedCaptchaError(); waitForCaptchaToBeSolved.mockRejectedValue(error); setupLink(CAPTCHA_ERROR_RESPONSE, SUCCESS_RESPONSE); - link.request(mockOperation()).subscribe({ - next: done.catch, - error: (result) => { - expect(result).toEqual(error); - expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); - expect(mockContext).not.toHaveBeenCalled(); - expect(mockLinkImplementation).toHaveBeenCalledTimes(1); - done(); - }, + return new Promise((resolve, reject) => { + link.request(mockOperation()).subscribe({ + next: reject, + error: (result) => { + expect(result).toEqual(error); + expect(waitForCaptchaToBeSolved).toHaveBeenCalledWith(CAPTCHA_SITE_KEY); + expect(mockContext).not.toHaveBeenCalled(); + expect(mockLinkImplementation).toHaveBeenCalledTimes(1); + resolve(); + }, + }); }); }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 085ab1c0c30..2fedbbecd64 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -36,7 +36,7 @@ describe('Ci variable modal', () => { const findAddorUpdateButton = () => findModal() .findAll(GlButton) - .wrappers.find((button) => button.props('variant') === 'success'); + .wrappers.find((button) => button.props('variant') === 'confirm'); const deleteVariableButton = () => findModal() .findAll(GlButton) diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index 426e6cae8fb..eb31fcd3ef4 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -86,10 +86,10 @@ describe('CI variable list store actions', () => { }); describe('deleteVariable', () => { - it('dispatch correct actions on successful deleted variable', (done) => { + it('dispatch correct actions on successful deleted variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.deleteVariable, {}, state, @@ -99,16 +99,13 @@ describe('CI variable list store actions', () => { { type: 'receiveDeleteVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on delete failure', (done) => { + it('should show flash error and set error in state on delete failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.deleteVariable, {}, state, @@ -120,19 +117,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('updateVariable', () => { - it('dispatch correct actions on successful updated variable', (done) => { + it('dispatch correct actions on successful updated variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.updateVariable, {}, state, @@ -142,16 +136,13 @@ describe('CI variable list store actions', () => { { type: 'receiveUpdateVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on update failure', (done) => { + it('should show flash error and set error in state on update failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.updateVariable, mockVariable, state, @@ -163,19 +154,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('addVariable', () => { - it('dispatch correct actions on successful added variable', (done) => { + it('dispatch correct actions on successful added variable', () => { mock.onPatch(state.endpoint).reply(200); - testAction( + return testAction( actions.addVariable, {}, state, @@ -185,16 +173,13 @@ describe('CI variable list store actions', () => { { type: 'receiveAddVariableSuccess' }, { type: 'fetchVariables' }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on add failure', (done) => { + it('should show flash error and set error in state on add failure', async () => { mock.onPatch(state.endpoint).reply(500, ''); - testAction( + await testAction( actions.addVariable, {}, state, @@ -206,19 +191,16 @@ describe('CI variable list store actions', () => { payload: payloadError, }, ], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, ); + expect(createFlash).toHaveBeenCalled(); }); }); describe('fetchVariables', () => { - it('dispatch correct actions on fetchVariables', (done) => { + it('dispatch correct actions on fetchVariables', () => { mock.onGet(state.endpoint).reply(200, { variables: mockData.mockVariables }); - testAction( + return testAction( actions.fetchVariables, {}, state, @@ -230,29 +212,24 @@ describe('CI variable list store actions', () => { payload: prepareDataForDisplay(mockData.mockVariables), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch variables failure', (done) => { + it('should show flash error and set error in state on fetch variables failure', async () => { mock.onGet(state.endpoint).reply(500); - testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }], () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the variables.', - }); - done(); + await testAction(actions.fetchVariables, {}, state, [], [{ type: 'requestVariables' }]); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the variables.', }); }); }); describe('fetchEnvironments', () => { - it('dispatch correct actions on fetchEnvironments', (done) => { + it('dispatch correct actions on fetchEnvironments', () => { Api.environments = jest.fn().mockResolvedValue({ data: mockData.mockEnvironments }); - testAction( + return testAction( actions.fetchEnvironments, {}, state, @@ -264,28 +241,17 @@ describe('CI variable list store actions', () => { payload: prepareEnvironments(mockData.mockEnvironments), }, ], - () => { - done(); - }, ); }); - it('should show flash error and set error in state on fetch environments failure', (done) => { + it('should show flash error and set error in state on fetch environments failure', async () => { Api.environments = jest.fn().mockRejectedValue(); - testAction( - actions.fetchEnvironments, - {}, - state, - [], - [{ type: 'requestEnvironments' }], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the environments information.', - }); - done(); - }, - ); + await testAction(actions.fetchEnvironments, {}, state, [], [{ type: 'requestEnvironments' }]); + + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was an error fetching the environments information.', + }); }); }); diff --git a/spec/frontend/clusters_list/components/agent_empty_state_spec.js b/spec/frontend/clusters_list/components/agent_empty_state_spec.js index ed2a0d0b97b..22775aa6603 100644 --- a/spec/frontend/clusters_list/components/agent_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/agent_empty_state_spec.js @@ -1,8 +1,6 @@ -import { GlEmptyState, GlSprintf, GlLink, GlButton } from '@gitlab/ui'; +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; -import { INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { helpPagePath } from '~/helpers/help_page_helper'; const emptyStateImage = '/path/to/image'; @@ -15,16 +13,12 @@ describe('AgentEmptyStateComponent', () => { }; const findInstallDocsLink = () => wrapper.findComponent(GlLink); - const findIntegrationButton = () => wrapper.findComponent(GlButton); const findEmptyState = () => wrapper.findComponent(GlEmptyState); beforeEach(() => { wrapper = shallowMountExtended(AgentEmptyState, { provide: provideData, - directives: { - GlModalDirective: createMockDirective(), - }, - stubs: { GlEmptyState, GlSprintf }, + stubs: { GlSprintf }, }); }); @@ -38,17 +32,7 @@ describe('AgentEmptyStateComponent', () => { expect(findEmptyState().exists()).toBe(true); }); - it('renders button for the agent registration', () => { - expect(findIntegrationButton().exists()).toBe(true); - }); - it('renders correct href attributes for the docs link', () => { expect(findInstallDocsLink().attributes('href')).toBe(installDocsUrl); }); - - it('renders correct modal id for the agent registration modal', () => { - const binding = getBinding(findIntegrationButton().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js index db723622a51..a466a35428a 100644 --- a/spec/frontend/clusters_list/components/agent_table_spec.js +++ b/spec/frontend/clusters_list/components/agent_table_spec.js @@ -9,7 +9,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import { clusterAgents, connectedTimeNow, connectedTimeInactive } from './mock_data'; const defaultConfigHelpUrl = - '/help/user/clusters/agent/install/index#create-an-agent-without-configuration-file'; + '/help/user/clusters/agent/install/index#create-an-agent-configuration-file'; const provideData = { gitlabVersion: '14.8', diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js index a80c8ffaad4..7f6ec2eb3a2 100644 --- a/spec/frontend/clusters_list/components/agent_token_spec.js +++ b/spec/frontend/clusters_list/components/agent_token_spec.js @@ -53,7 +53,7 @@ describe('InstallAgentModal', () => { }); it('shows agent token as an input value', () => { - expect(findInput().props('value')).toBe('agent-token'); + expect(findInput().props('value')).toBe(agentToken); }); it('renders a copy button', () => { @@ -65,12 +65,12 @@ describe('InstallAgentModal', () => { }); it('shows warning alert', () => { - expect(findAlert().props('title')).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); + expect(findAlert().text()).toBe(I18N_AGENT_TOKEN.tokenSingleUseWarningTitle); }); it('shows code block with agent installation command', () => { - expect(findCodeBlock().props('code')).toContain('--agent-token=agent-token'); - expect(findCodeBlock().props('code')).toContain('--kas-address=kas.example.com'); + expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`); + expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`); }); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index 3cfa4b92bc0..92cfff7d490 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -308,7 +308,7 @@ describe('Agents', () => { }); it('displays an alert message', () => { - expect(findAlert().text()).toBe('An error occurred while loading your Agents'); + expect(findAlert().text()).toBe('An error occurred while loading your agents'); }); }); diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js index eca2b1f5cb1..197735d3c77 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -1,5 +1,6 @@ import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { ENTER_KEY } from '~/lib/utils/keys'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; @@ -18,6 +19,7 @@ describe('AvailableAgentsDropdown', () => { propsData, stubs: { GlDropdown }, }); + wrapper.vm.$refs.dropdown.hide = jest.fn(); }; afterEach(() => { @@ -96,6 +98,25 @@ describe('AvailableAgentsDropdown', () => { expect(findDropdown().props('text')).toBe('new-agent'); }); }); + + describe('click enter to register new agent without configuration', () => { + beforeEach(async () => { + await findSearchInput().vm.$emit('input', 'new-agent'); + await findSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + }); + + it('emits agentSelected with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]); + }); + + it('marks the clicked item as selected', () => { + expect(findDropdown().props('text')).toBe('new-agent'); + }); + + it('closes the dropdown', () => { + expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + }); + }); }); describe('registration in progress', () => { diff --git a/spec/frontend/clusters_list/components/clusters_actions_spec.js b/spec/frontend/clusters_list/components/clusters_actions_spec.js index 312df12ab5f..21dcc66c639 100644 --- a/spec/frontend/clusters_list/components/clusters_actions_spec.js +++ b/spec/frontend/clusters_list/components/clusters_actions_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersActions from '~/clusters_list/components/clusters_actions.vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; @@ -7,12 +7,14 @@ import { INSTALL_AGENT_MODAL_ID, CLUSTERS_ACTIONS } from '~/clusters_list/consta describe('ClustersActionsComponent', () => { let wrapper; - const newClusterPath = 'path/to/create/cluster'; + const newClusterPath = 'path/to/add/cluster'; const addClusterPath = 'path/to/connect/existing/cluster'; + const newClusterDocsPath = 'path/to/create/new/cluster'; const defaultProvide = { newClusterPath, addClusterPath, + newClusterDocsPath, canAddCluster: true, displayClusterAgents: true, certificateBasedClustersEnabled: true, @@ -20,12 +22,13 @@ describe('ClustersActionsComponent', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdownItemIds = () => findDropdownItems().wrappers.map((x) => x.attributes('data-testid')); + const findDropdownItemTexts = () => findDropdownItems().wrappers.map((x) => x.text()); const findNewClusterLink = () => wrapper.findByTestId('new-cluster-link'); + const findNewClusterDocsLink = () => wrapper.findByTestId('create-cluster-link'); const findConnectClusterLink = () => wrapper.findByTestId('connect-cluster-link'); - const findConnectNewAgentLink = () => wrapper.findByTestId('connect-new-agent-link'); - const findConnectWithAgentButton = () => wrapper.findComponent(GlButton); const createWrapper = (provideData = {}) => { wrapper = shallowMountExtended(ClustersActions, { @@ -35,7 +38,6 @@ describe('ClustersActionsComponent', () => { }, directives: { GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), }, }); }; @@ -49,12 +51,15 @@ describe('ClustersActionsComponent', () => { }); describe('when the certificate based clusters are enabled', () => { it('renders actions menu', () => { - expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.actionsButton); + expect(findDropdown().exists()).toBe(true); }); - it('renders correct href attributes for the links', () => { - expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); - expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); + it('shows split button in dropdown', () => { + expect(findDropdown().props('split')).toBe(true); + }); + + it("doesn't show the tooltip", () => { + expect(findTooltip().exists()).toBe(false); }); describe('when user cannot add clusters', () => { @@ -67,8 +72,7 @@ describe('ClustersActionsComponent', () => { }); it('shows tooltip explaining why dropdown is disabled', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); + expect(findTooltip().attributes('title')).toBe(CLUSTERS_ACTIONS.dropdownDisabledHint); }); it('does not bind split dropdown button', () => { @@ -79,33 +83,36 @@ describe('ClustersActionsComponent', () => { }); describe('when on project level', () => { - it('renders a dropdown with 3 actions items', () => { - expect(findDropdownItemIds()).toEqual([ - 'connect-new-agent-link', - 'new-cluster-link', - 'connect-cluster-link', - ]); + it(`displays default action as ${CLUSTERS_ACTIONS.connectWithAgent}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectWithAgent); }); - it('renders correct modal id for the agent link', () => { - const binding = getBinding(findConnectNewAgentLink().element, 'gl-modal-directive'); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders a dropdown with 3 actions items', () => { + expect(findDropdownItemIds()).toEqual([ + 'create-cluster-link', + 'new-cluster-link', + 'connect-cluster-link', + ]); }); - it('shows split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(true); + it('renders correct texts for the dropdown items', () => { + expect(findDropdownItemTexts()).toEqual([ + CLUSTERS_ACTIONS.createCluster, + CLUSTERS_ACTIONS.createClusterCertificate, + CLUSTERS_ACTIONS.connectClusterCertificate, + ]); }); - it('binds split button with modal id', () => { - const binding = getBinding(findDropdown().element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); + expect(findConnectClusterLink().attributes('href')).toBe(addClusterPath); }); }); @@ -114,17 +121,20 @@ describe('ClustersActionsComponent', () => { createWrapper({ displayClusterAgents: false }); }); - it('renders a dropdown with 2 actions items', () => { - expect(findDropdownItemIds()).toEqual(['new-cluster-link', 'connect-cluster-link']); + it(`displays default action as ${CLUSTERS_ACTIONS.connectClusterDeprecated}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectClusterDeprecated); }); - it('shows tooltip', () => { - const tooltip = getBinding(findDropdown().element, 'gl-tooltip'); - expect(tooltip.value).toBe(CLUSTERS_ACTIONS.connectExistingCluster); + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['new-cluster-link']); }); - it('does not show split button in dropdown', () => { - expect(findDropdown().props('split')).toBe(false); + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createClusterDeprecated]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterLink().attributes('href')).toBe(newClusterPath); }); it('does not bind dropdown button to modal', () => { @@ -140,17 +150,26 @@ describe('ClustersActionsComponent', () => { createWrapper({ certificateBasedClustersEnabled: false }); }); - it('it does not show the the dropdown', () => { - expect(findDropdown().exists()).toBe(false); + it(`displays default action as ${CLUSTERS_ACTIONS.connectCluster}`, () => { + expect(findDropdown().props('text')).toBe(CLUSTERS_ACTIONS.connectCluster); }); - it('shows the connect with agent button', () => { - expect(findConnectWithAgentButton().props()).toMatchObject({ - disabled: !defaultProvide.canAddCluster, - category: 'primary', - variant: 'confirm', - }); - expect(findConnectWithAgentButton().text()).toBe(CLUSTERS_ACTIONS.connectWithAgent); + it('renders correct modal id for the default action', () => { + const binding = getBinding(findDropdown().element, 'gl-modal-directive'); + + expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); + }); + + it('renders a dropdown with 1 action item', () => { + expect(findDropdownItemIds()).toEqual(['create-cluster-link']); + }); + + it('renders correct text for the dropdown item', () => { + expect(findDropdownItemTexts()).toEqual([CLUSTERS_ACTIONS.createCluster]); + }); + + it('renders correct href attributes for the links', () => { + expect(findNewClusterDocsLink().attributes('href')).toBe(newClusterDocsPath); }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js index fe2189296a6..2c3a224f3c8 100644 --- a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -1,10 +1,8 @@ -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlEmptyState } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; -import ClusterStore from '~/clusters_list/store'; const clustersEmptyStateImage = 'path/to/svg'; -const addClusterPath = '/path/to/connect/cluster'; const emptyStateHelpText = 'empty state text'; describe('ClustersEmptyStateComponent', () => { @@ -12,52 +10,28 @@ describe('ClustersEmptyStateComponent', () => { const defaultProvideData = { clustersEmptyStateImage, - addClusterPath, }; - const findButton = () => wrapper.findComponent(GlButton); const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); - const createWrapper = ({ - provideData = { emptyStateHelpText: null }, - isChildComponent = false, - canAddCluster = true, - } = {}) => { + const createWrapper = ({ provideData = { emptyStateHelpText: null } } = {}) => { wrapper = shallowMountExtended(ClustersEmptyState, { - store: ClusterStore({ canAddCluster }), - propsData: { isChildComponent }, provide: { ...defaultProvideData, ...provideData }, stubs: { GlEmptyState }, }); }; - beforeEach(() => { - createWrapper(); - }); - afterEach(() => { wrapper.destroy(); }); - describe('when the component is loaded independently', () => { - it('should render the action button', () => { - expect(findButton().exists()).toBe(true); - }); - }); - describe('when the help text is not provided', () => { - it('should not render the empty state text', () => { - expect(findEmptyStateText().exists()).toBe(false); - }); - }); - - describe('when the component is loaded as a child component', () => { beforeEach(() => { - createWrapper({ isChildComponent: true }); + createWrapper(); }); - it('should not render the action button', () => { - expect(findButton().exists()).toBe(false); + it('should not render the empty state text', () => { + expect(findEmptyStateText().exists()).toBe(false); }); }); @@ -70,13 +44,4 @@ describe('ClustersEmptyStateComponent', () => { expect(findEmptyStateText().text()).toBe(emptyStateHelpText); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ canAddCluster: false }); - }); - it('should disable the button', () => { - expect(findButton().props('disabled')).toBe(true); - }); - }); }); diff --git a/spec/frontend/clusters_list/components/clusters_view_all_spec.js b/spec/frontend/clusters_list/components/clusters_view_all_spec.js index 2c1e3d909cc..b4eb9242003 100644 --- a/spec/frontend/clusters_list/components/clusters_view_all_spec.js +++ b/spec/frontend/clusters_list/components/clusters_view_all_spec.js @@ -1,24 +1,21 @@ -import { GlCard, GlLoadingIcon, GlButton, GlSprintf, GlBadge } from '@gitlab/ui'; +import { GlCard, GlLoadingIcon, GlSprintf, GlBadge } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ClustersViewAll from '~/clusters_list/components/clusters_view_all.vue'; import Agents from '~/clusters_list/components/agents.vue'; import Clusters from '~/clusters_list/components/clusters.vue'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { AGENT, CERTIFICATE_BASED, AGENT_CARD_INFO, CERTIFICATE_BASED_CARD_INFO, MAX_CLUSTERS_LIST, - INSTALL_AGENT_MODAL_ID, } from '~/clusters_list/constants'; import { sprintf } from '~/locale'; Vue.use(Vuex); -const addClusterPath = '/path/to/add/cluster'; const defaultBranchName = 'default-branch'; describe('ClustersViewAllComponent', () => { @@ -32,11 +29,6 @@ describe('ClustersViewAllComponent', () => { defaultBranchName, }; - const defaultProvide = { - addClusterPath, - canAddCluster: true, - }; - const entryData = { loadingClusters: false, totalClusters: 0, @@ -46,37 +38,20 @@ describe('ClustersViewAllComponent', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findAgentsComponent = () => wrapper.findComponent(Agents); const findClustersComponent = () => wrapper.findComponent(Clusters); - const findInstallAgentButtonTooltip = () => wrapper.findByTestId('install-agent-button-tooltip'); - const findConnectExistingClusterButtonTooltip = () => - wrapper.findByTestId('connect-existing-cluster-button-tooltip'); const findCardsContainer = () => wrapper.findByTestId('clusters-cards-container'); const findAgentCardTitle = () => wrapper.findByTestId('agent-card-title'); const findRecommendedBadge = () => wrapper.findComponent(GlBadge); const findClustersCardTitle = () => wrapper.findByTestId('clusters-card-title'); - const findFooterButton = (line) => findCards().at(line).findComponent(GlButton); - const getTooltipText = (el) => { - const binding = getBinding(el, 'gl-tooltip'); - - return binding.value; - }; const createStore = (initialState) => new Vuex.Store({ state: initialState, }); - const createWrapper = ({ initialState = entryData, provideData } = {}) => { + const createWrapper = ({ initialState = entryData } = {}) => { wrapper = shallowMountExtended(ClustersViewAll, { store: createStore(initialState), propsData, - provide: { - ...defaultProvide, - ...provideData, - }, - directives: { - GlModalDirective: createMockDirective(), - GlTooltip: createMockDirective(), - }, stubs: { GlCard, GlSprintf }, }); }; @@ -138,25 +113,10 @@ describe('ClustersViewAllComponent', () => { expect(findAgentsComponent().props('defaultBranchName')).toBe(defaultBranchName); }); - it('should show install new Agent button in the footer', () => { - expect(findFooterButton(0).exists()).toBe(true); - expect(findFooterButton(0).props('disabled')).toBe(false); - }); - - it('does not show tooltip for install new Agent button', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe(''); - }); - describe('when there are no agents', () => { it('should show the empty title', () => { expect(findAgentCardTitle().text()).toBe(AGENT_CARD_INFO.emptyTitle); }); - - it('should render correct modal id for the agent link', () => { - const binding = getBinding(findFooterButton(0).element, 'gl-modal-directive'); - - expect(binding.value).toBe(INSTALL_AGENT_MODAL_ID); - }); }); describe('when the agents are present', () => { @@ -191,22 +151,6 @@ describe('ClustersViewAllComponent', () => { }); }); }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(0).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findInstallAgentButtonTooltip().element)).toBe( - AGENT_CARD_INFO.installAgentDisabledHint, - ); - }); - }); }); describe('clusters tab', () => { @@ -214,43 +158,10 @@ describe('ClustersViewAllComponent', () => { expect(findClustersComponent().props('limit')).toBe(MAX_CLUSTERS_LIST); }); - it('should pass the is-child-component prop', () => { - expect(findClustersComponent().props('isChildComponent')).toBe(true); - }); - describe('when there are no clusters', () => { it('should show the empty title', () => { expect(findClustersCardTitle().text()).toBe(CERTIFICATE_BASED_CARD_INFO.emptyTitle); }); - - it('should show install new cluster button in the footer', () => { - expect(findFooterButton(1).exists()).toBe(true); - expect(findFooterButton(1).props('disabled')).toBe(false); - }); - - it('should render correct href for the button in the footer', () => { - expect(findFooterButton(1).attributes('href')).toBe(addClusterPath); - }); - - it('does not show tooltip for install new cluster button', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe(''); - }); - }); - - describe('when the user cannot add clusters', () => { - beforeEach(() => { - createWrapper({ provideData: { canAddCluster: false } }); - }); - - it('should disable the button', () => { - expect(findFooterButton(1).props('disabled')).toBe(true); - }); - - it('should show a tooltip explaining why the button is disabled', () => { - expect(getTooltipText(findConnectExistingClusterButtonTooltip().element)).toBe( - CERTIFICATE_BASED_CARD_INFO.connectExistingClusterDisabledHint, - ); - }); }); describe('when the clusters are present', () => { diff --git a/spec/frontend/clusters_list/mocks/apollo.js b/spec/frontend/clusters_list/mocks/apollo.js index b0f2978a230..3467b4c665c 100644 --- a/spec/frontend/clusters_list/mocks/apollo.js +++ b/spec/frontend/clusters_list/mocks/apollo.js @@ -1,4 +1,5 @@ const agent = { + __typename: 'ClusterAgent', id: 'agent-id', name: 'agent-name', webPath: 'agent-webPath', diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index f4b69053e14..7663f329b3f 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -24,14 +24,12 @@ describe('Clusters store actions', () => { captureException.mockRestore(); }); - it('should report sentry error', (done) => { + it('should report sentry error', async () => { const sentryError = new Error('New Sentry Error'); const tag = 'sentryErrorTag'; - testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], [], () => { - expect(captureException).toHaveBeenCalledWith(sentryError); - done(); - }); + await testAction(actions.reportSentryError, { error: sentryError, tag }, {}, [], []); + expect(captureException).toHaveBeenCalledWith(sentryError); }); }); @@ -62,10 +60,10 @@ describe('Clusters store actions', () => { afterEach(() => mock.restore()); - it('should commit SET_CLUSTERS_DATA with received response', (done) => { + it('should commit SET_CLUSTERS_DATA with received response', () => { mock.onGet().reply(200, apiData, headers); - testAction( + return testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -75,14 +73,13 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => done(), ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(400, 'Not Found'); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -100,13 +97,10 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('error'), - }); - done(); - }, ); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('error'), + }); }); describe('multiple api requests', () => { @@ -128,8 +122,8 @@ describe('Clusters store actions', () => { pollStop.mockRestore(); }); - it('should stop polling after MAX Requests', (done) => { - testAction( + it('should stop polling after MAX Requests', async () => { + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -139,47 +133,43 @@ describe('Clusters store actions', () => { { type: types.SET_LOADING_CLUSTERS, payload: false }, ], [], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); + ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + + return waitForPromises() + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(2); expect(pollStop).toHaveBeenCalledTimes(0); jest.advanceTimersByTime(pollInterval); - - waitForPromises() - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(2); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); - expect(pollStop).toHaveBeenCalledTimes(0); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - // Stops poll once it exceeds the MAX_REQUESTS limit - expect(pollStop).toHaveBeenCalledTimes(1); - jest.advanceTimersByTime(pollInterval); - }) - .then(() => waitForPromises()) - .then(() => { - // Additional poll requests are not made once pollStop is called - expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); - expect(pollStop).toHaveBeenCalledTimes(1); - }) - .then(done) - .catch(done.fail); - }, - ); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS); + expect(pollStop).toHaveBeenCalledTimes(0); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + // Stops poll once it exceeds the MAX_REQUESTS limit + expect(pollStop).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(pollInterval); + }) + .then(() => waitForPromises()) + .then(() => { + // Additional poll requests are not made once pollStop is called + expect(pollRequest).toHaveBeenCalledTimes(MAX_REQUESTS + 1); + expect(pollStop).toHaveBeenCalledTimes(1); + }); }); - it('should stop polling and report to Sentry when data is invalid', (done) => { + it('should stop polling and report to Sentry when data is invalid', async () => { const badApiResponse = { clusters: {} }; mock.onGet().reply(200, badApiResponse, pollHeaders); - testAction( + await testAction( actions.fetchClusters, { endpoint: apiData.endpoint }, {}, @@ -202,12 +192,9 @@ describe('Clusters store actions', () => { }, }, ], - () => { - expect(pollRequest).toHaveBeenCalledTimes(1); - expect(pollStop).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(pollRequest).toHaveBeenCalledTimes(1); + expect(pollStop).toHaveBeenCalledTimes(1); }); }); }); diff --git a/spec/frontend/code_navigation/components/app_spec.js b/spec/frontend/code_navigation/components/app_spec.js index 0d7c0360e9b..f2f97092c5a 100644 --- a/spec/frontend/code_navigation/components/app_spec.js +++ b/spec/frontend/code_navigation/components/app_spec.js @@ -38,12 +38,17 @@ describe('Code navigation app component', () => { const codeNavigationPath = 'code/nav/path.js'; const path = 'blob/path.js'; const definitionPathPrefix = 'path/prefix'; + const wrapTextNodes = true; - factory({}, { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix }); + factory( + {}, + { codeNavigationPath, blobPath: path, pathPrefix: definitionPathPrefix, wrapTextNodes }, + ); expect(setInitialData).toHaveBeenCalledWith(expect.anything(), { blobs: [{ codeNavigationPath, path }], definitionPathPrefix, + wrapTextNodes, }); }); diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js index 73f935deeca..c26416aca94 100644 --- a/spec/frontend/code_navigation/store/actions_spec.js +++ b/spec/frontend/code_navigation/store/actions_spec.js @@ -7,15 +7,16 @@ import axios from '~/lib/utils/axios_utils'; jest.mock('~/code_navigation/utils'); describe('Code navigation actions', () => { + const wrapTextNodes = true; + describe('setInitialData', () => { - it('commits SET_INITIAL_DATA', (done) => { - testAction( + it('commits SET_INITIAL_DATA', () => { + return testAction( actions.setInitialData, - { projectPath: 'test' }, + { projectPath: 'test', wrapTextNodes }, {}, - [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test' } }], + [{ type: 'SET_INITIAL_DATA', payload: { projectPath: 'test', wrapTextNodes } }], [], - done, ); }); }); @@ -30,7 +31,7 @@ describe('Code navigation actions', () => { const codeNavigationPath = 'gitlab-org/gitlab-shell/-/jobs/1114/artifacts/raw/lsif/cmd/check/main.go.json'; - const state = { blobs: [{ path: 'index.js', codeNavigationPath }] }; + const state = { blobs: [{ path: 'index.js', codeNavigationPath }], wrapTextNodes }; beforeEach(() => { window.gon = { api_version: '1' }; @@ -57,8 +58,8 @@ describe('Code navigation actions', () => { ]); }); - it('commits REQUEST_DATA_SUCCESS with normalized data', (done) => { - testAction( + it('commits REQUEST_DATA_SUCCESS with normalized data', () => { + return testAction( actions.fetchData, null, state, @@ -80,12 +81,11 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); - it('calls addInteractionClass with data', (done) => { - testAction( + it('calls addInteractionClass with data', () => { + return testAction( actions.fetchData, null, state, @@ -107,16 +107,17 @@ describe('Code navigation actions', () => { }, ], [], - ) - .then(() => { - expect(addInteractionClass).toHaveBeenCalledWith('index.js', { + ).then(() => { + expect(addInteractionClass).toHaveBeenCalledWith({ + path: 'index.js', + d: { start_line: 0, start_char: 0, hover: { value: '123' }, - }); - }) - .then(done) - .catch(done.fail); + }, + wrapTextNodes, + }); + }); }); }); @@ -125,14 +126,13 @@ describe('Code navigation actions', () => { mock.onGet(codeNavigationPath).replyOnce(500); }); - it('dispatches requestDataError', (done) => { - testAction( + it('dispatches requestDataError', () => { + return testAction( actions.fetchData, null, state, [{ type: 'REQUEST_DATA' }], [{ type: 'requestDataError' }], - done, ); }); }); @@ -144,14 +144,19 @@ describe('Code navigation actions', () => { data: { 'index.js': { '0:0': 'test', '1:1': 'console.log' }, }, + wrapTextNodes, }; actions.showBlobInteractionZones({ state }, 'index.js'); expect(addInteractionClass).toHaveBeenCalled(); expect(addInteractionClass.mock.calls.length).toBe(2); - expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']); - expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']); + expect(addInteractionClass.mock.calls[0]).toEqual([ + { path: 'index.js', d: 'test', wrapTextNodes }, + ]); + expect(addInteractionClass.mock.calls[1]).toEqual([ + { path: 'index.js', d: 'console.log', wrapTextNodes }, + ]); }); it('does not call addInteractionClass when no data exists', () => { @@ -175,20 +180,20 @@ describe('Code navigation actions', () => { target = document.querySelector('.js-test'); }); - it('returns early when no data exists', (done) => { - testAction(actions.showDefinition, { target }, {}, [], [], done); + it('returns early when no data exists', () => { + return testAction(actions.showDefinition, { target }, {}, [], []); }); - it('commits SET_CURRENT_DEFINITION when target is not code navitation element', (done) => { - testAction(actions.showDefinition, { target }, { data: {} }, [], [], done); + it('commits SET_CURRENT_DEFINITION when target is not code navitation element', () => { + return testAction(actions.showDefinition, { target }, { data: {} }, [], []); }); - it('commits SET_CURRENT_DEFINITION with LSIF data', (done) => { + it('commits SET_CURRENT_DEFINITION with LSIF data', () => { target.classList.add('js-code-navigation'); target.setAttribute('data-line-index', '0'); target.setAttribute('data-char-index', '0'); - testAction( + return testAction( actions.showDefinition, { target }, { data: { 'index.js': { '0:0': { hover: 'test' } } } }, @@ -203,7 +208,6 @@ describe('Code navigation actions', () => { }, ], [], - done, ); }); diff --git a/spec/frontend/code_navigation/store/mutations_spec.js b/spec/frontend/code_navigation/store/mutations_spec.js index cb10729f4b6..b2f1b3bddfd 100644 --- a/spec/frontend/code_navigation/store/mutations_spec.js +++ b/spec/frontend/code_navigation/store/mutations_spec.js @@ -13,10 +13,12 @@ describe('Code navigation mutations', () => { mutations.SET_INITIAL_DATA(state, { blobs: ['test'], definitionPathPrefix: 'https://test.com/blob/main', + wrapTextNodes: true, }); expect(state.blobs).toEqual(['test']); expect(state.definitionPathPrefix).toBe('https://test.com/blob/main'); + expect(state.wrapTextNodes).toBe(true); }); }); diff --git a/spec/frontend/code_navigation/utils/index_spec.js b/spec/frontend/code_navigation/utils/index_spec.js index 6a01249d2a3..682c8bce8c5 100644 --- a/spec/frontend/code_navigation/utils/index_spec.js +++ b/spec/frontend/code_navigation/utils/index_spec.js @@ -45,14 +45,42 @@ describe('addInteractionClass', () => { ${0} | ${0} | ${0} ${0} | ${8} | ${2} ${1} | ${0} | ${0} + ${1} | ${0} | ${0} `( 'it sets code navigation attributes for line $line and character $char', ({ line, char, index }) => { - addInteractionClass('index.js', { start_line: line, start_char: char }); + addInteractionClass({ path: 'index.js', d: { start_line: line, start_char: char } }); expect(document.querySelectorAll(`#LC${line + 1} span`)[index].classList).toContain( 'js-code-navigation', ); }, ); + + describe('wrapTextNodes', () => { + beforeEach(() => { + setFixtures( + '
      Text
      ', + ); + }); + + const params = { path: 'index.js', d: { start_line: 0, start_char: 0 } }; + const findAllSpans = () => document.querySelectorAll('#LC1 span'); + + it('does not wrap text nodes by default', () => { + addInteractionClass(params); + const spans = findAllSpans(); + expect(spans.length).toBe(0); + }); + + it('wraps text nodes if wrapTextNodes is true', () => { + addInteractionClass({ ...params, wrapTextNodes: true }); + const spans = findAllSpans(); + + expect(spans.length).toBe(3); + expect(spans[0].textContent).toBe(' '); + expect(spans[1].textContent).toBe('Text'); + expect(spans[2].textContent).toBe(' '); + }); + }); }); diff --git a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js index 1a2e188e7ae..b1c8ba48475 100644 --- a/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js +++ b/spec/frontend/commit/commit_box_pipeline_mini_graph_spec.js @@ -1,7 +1,18 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; import CommitBoxPipelineMiniGraph from '~/projects/commit_box/info/components/commit_box_pipeline_mini_graph.vue'; -import { mockStages } from './mock_data'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from '~/projects/commit_box/info/graphql/queries/get_pipeline_stages.query.graphql'; +import { mockPipelineStagesQueryResponse, mockStages } from './mock_data'; + +jest.mock('~/flash'); + +Vue.use(VueApollo); describe('Commit box pipeline mini graph', () => { let wrapper; @@ -10,34 +21,36 @@ describe('Commit box pipeline mini graph', () => { const findUpstream = () => wrapper.findByTestId('commit-box-mini-graph-upstream'); const findDownstream = () => wrapper.findByTestId('commit-box-mini-graph-downstream'); - const createComponent = () => { + const stagesHandler = jest.fn().mockResolvedValue(mockPipelineStagesQueryResponse); + + const createComponent = ({ props = {} } = {}) => { + const handlers = [ + [getLinkedPipelinesQuery, {}], + [getPipelineStagesQuery, stagesHandler], + ]; + wrapper = extendedWrapper( shallowMount(CommitBoxPipelineMiniGraph, { propsData: { stages: mockStages, + ...props, }, - mocks: { - $apollo: { - queries: { - pipeline: { - loading: false, - }, - }, - }, - }, + apolloProvider: createMockApollo(handlers), }), ); - }; - beforeEach(() => { - createComponent(); - }); + return waitForPromises(); + }; afterEach(() => { wrapper.destroy(); }); describe('linked pipelines', () => { + beforeEach(async () => { + await createComponent(); + }); + it('should display the mini pipeine graph', () => { expect(findMiniGraph().exists()).toBe(true); }); @@ -47,4 +60,18 @@ describe('Commit box pipeline mini graph', () => { expect(findDownstream().exists()).toBe(false); }); }); + + describe('when data is mismatched', () => { + beforeEach(async () => { + await createComponent({ props: { stages: [] } }); + }); + + it('calls create flash with expected arguments', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem handling the pipeline data.', + captureError: true, + error: new Error('Rest stages and graphQl stages must be the same length'), + }); + }); + }); }); diff --git a/spec/frontend/commit/components/commit_box_pipeline_status_spec.js b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js new file mode 100644 index 00000000000..db7b7b45397 --- /dev/null +++ b/spec/frontend/commit/components/commit_box_pipeline_status_spec.js @@ -0,0 +1,150 @@ +import { GlLoadingIcon, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import CommitBoxPipelineStatus from '~/projects/commit_box/info/components/commit_box_pipeline_status.vue'; +import { + COMMIT_BOX_POLL_INTERVAL, + PIPELINE_STATUS_FETCH_ERROR, +} from '~/projects/commit_box/info/constants'; +import getLatestPipelineStatusQuery from '~/projects/commit_box/info/graphql/queries/get_latest_pipeline_status.query.graphql'; +import * as graphQlUtils from '~/pipelines/components/graph/utils'; +import { mockPipelineStatusResponse } from '../mock_data'; + +const mockProvide = { + fullPath: 'root/ci-project', + iid: '46', + graphqlResourceEtag: '/api/graphql:pipelines/id/320', +}; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +describe('Commit box pipeline status', () => { + let wrapper; + + const statusSuccessHandler = jest.fn().mockResolvedValue(mockPipelineStatusResponse); + const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findStatusIcon = () => wrapper.findComponent(CiIcon); + const findPipelineLink = () => wrapper.findComponent(GlLink); + + const advanceToNextFetch = () => { + jest.advanceTimersByTime(COMMIT_BOX_POLL_INTERVAL); + }; + + const createMockApolloProvider = (handler) => { + const requestHandlers = [[getLatestPipelineStatusQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const createComponent = (handler = statusSuccessHandler) => { + wrapper = shallowMount(CommitBoxPipelineStatus, { + provide: { + ...mockProvide, + }, + apolloProvider: createMockApolloProvider(handler), + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('loading state', () => { + it('should display loading state when loading', () => { + createComponent(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findStatusIcon().exists()).toBe(false); + }); + }); + + describe('loaded state', () => { + beforeEach(async () => { + createComponent(); + + await waitForPromises(); + }); + + it('should display pipeline status after the query is resolved successfully', async () => { + expect(findStatusIcon().exists()).toBe(true); + + expect(findLoadingIcon().exists()).toBe(false); + expect(createFlash).toHaveBeenCalledTimes(0); + }); + + it('should link to the latest pipeline', () => { + const { + data: { + project: { + pipeline: { + detailedStatus: { detailsPath }, + }, + }, + }, + } = mockPipelineStatusResponse; + + expect(findPipelineLink().attributes('href')).toBe(detailsPath); + }); + }); + + describe('error state', () => { + it('createFlash should show if there is an error fetching the pipeline status', async () => { + createComponent(failedHandler); + + await waitForPromises(); + + expect(createFlash).toHaveBeenCalledWith({ + message: PIPELINE_STATUS_FETCH_ERROR, + }); + }); + }); + + describe('polling', () => { + it('polling interval is set for pipeline stages', () => { + createComponent(); + + const expectedInterval = wrapper.vm.$apollo.queries.pipelineStatus.options.pollInterval; + + expect(expectedInterval).toBe(COMMIT_BOX_POLL_INTERVAL); + }); + + it('polls for pipeline status', async () => { + createComponent(); + + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(1); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(2); + + advanceToNextFetch(); + await waitForPromises(); + + expect(statusSuccessHandler).toHaveBeenCalledTimes(3); + }); + + it('toggles pipelineStatus polling with visibility check', async () => { + jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility'); + + createComponent(); + + await waitForPromises(); + + expect(graphQlUtils.toggleQueryPollingByVisibility).toHaveBeenCalledWith( + wrapper.vm.$apollo.queries.pipelineStatus, + ); + }); + }); +}); diff --git a/spec/frontend/commit/mock_data.js b/spec/frontend/commit/mock_data.js index ef018a4fbd7..8db162c07c2 100644 --- a/spec/frontend/commit/mock_data.js +++ b/spec/frontend/commit/mock_data.js @@ -115,3 +115,49 @@ export const mockStages = [ dropdown_path: '/root/ci-project/-/pipelines/611/stage.json?stage=qa', }, ]; + +export const mockPipelineStagesQueryResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + stages: { + nodes: [ + { + __typename: 'CiStage', + id: 'gid://gitlab/Ci::Stage/409', + name: 'build', + detailedStatus: { + id: 'success-409-409', + group: 'success', + icon: 'status_success', + __typename: 'DetailedStatus', + }, + }, + ], + }, + }, + }, + }, +}; + +export const mockPipelineStatusResponse = { + data: { + project: { + id: 'gid://gitlab/Project/20', + pipeline: { + id: 'gid://gitlab/Ci::Pipeline/320', + detailedStatus: { + id: 'pending-320-320', + detailsPath: '/root/ci-project/-/pipelines/320', + icon: 'status_pending', + group: 'pending', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/commit/pipelines/pipelines_table_spec.js b/spec/frontend/commit/pipelines/pipelines_table_spec.js index 203a4d23160..9b01af1e585 100644 --- a/spec/frontend/commit/pipelines/pipelines_table_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_table_spec.js @@ -120,18 +120,20 @@ describe('Pipelines table in Commits and Merge requests', () => { }); describe('pipeline badge counts', () => { - it('should receive update-pipelines-count event', (done) => { + it('should receive update-pipelines-count event', () => { const element = document.createElement('div'); document.body.appendChild(element); - element.addEventListener('update-pipelines-count', (event) => { - expect(event.detail.pipelineCount).toEqual(10); - done(); - }); + return new Promise((resolve) => { + element.addEventListener('update-pipelines-count', (event) => { + expect(event.detail.pipelineCount).toEqual(10); + resolve(); + }); - createComponent(); + createComponent(); - element.appendChild(wrapper.vm.$el); + element.appendChild(wrapper.vm.$el); + }); }); }); }); diff --git a/spec/frontend/commit/pipelines/utils_spec.js b/spec/frontend/commit/pipelines/utils_spec.js new file mode 100644 index 00000000000..472e35a6eb3 --- /dev/null +++ b/spec/frontend/commit/pipelines/utils_spec.js @@ -0,0 +1,59 @@ +import { formatStages } from '~/projects/commit_box/info/utils'; + +const graphqlStage = [ + { + __typename: 'CiStage', + name: 'deploy', + detailedStatus: { + __typename: 'DetailedStatus', + icon: 'status_success', + group: 'success', + id: 'success-409-409', + }, + }, +]; + +const restStage = [ + { + name: 'deploy', + title: 'deploy: passed', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + tooltip: 'passed', + has_details: true, + details_path: '/root/ci-project/-/pipelines/318#deploy', + illustration: null, + favicon: + '/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png', + }, + path: '/root/ci-project/-/pipelines/318#deploy', + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + }, +]; + +describe('Utils', () => { + it('combines REST and GraphQL stages correctly for component', () => { + expect(formatStages(graphqlStage, restStage)).toEqual([ + { + dropdown_path: '/root/ci-project/-/pipelines/318/stage.json?stage=deploy', + name: 'deploy', + status: { + __typename: 'DetailedStatus', + group: 'success', + icon: 'status_success', + id: 'success-409-409', + }, + title: 'deploy: passed', + }, + ]); + }); + + it('throws an error if arrays are not the same length', () => { + expect(() => { + formatStages(graphqlStage, []); + }).toThrow('Rest stages and graphQl stages must be the same length'); + }); +}); diff --git a/spec/frontend/commits_spec.js b/spec/frontend/commits_spec.js index 8189ebe6e55..a049a6997f0 100644 --- a/spec/frontend/commits_spec.js +++ b/spec/frontend/commits_spec.js @@ -70,29 +70,17 @@ describe('Commits List', () => { mock.restore(); }); - it('should save the last search string', (done) => { + it('should save the last search string', async () => { commitsList.searchField.val('GitLab'); - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual('GitLab'); - - done(); - }) - .catch(done.fail); + await commitsList.filterResults(); + expect(ajaxSpy).toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual('GitLab'); }); - it('should not make ajax call if the input does not change', (done) => { - commitsList - .filterResults() - .then(() => { - expect(ajaxSpy).not.toHaveBeenCalled(); - expect(commitsList.lastSearch).toEqual(''); - - done(); - }) - .catch(done.fail); + it('should not make ajax call if the input does not change', async () => { + await commitsList.filterResults(); + expect(ajaxSpy).not.toHaveBeenCalled(); + expect(commitsList.lastSearch).toEqual(''); }); }); }); diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index c2fa6556847..d9f161b47b1 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -12,7 +12,7 @@ exports[`Confidential merge request project form group component renders empty s

      No forks are available to you. @@ -27,7 +27,7 @@ exports[`Confidential merge request project form group component renders empty s and set the fork's visibility to private. @@ -62,13 +62,13 @@ exports[`Confidential merge request project form group component renders fork dr />

      To protect this issue's confidentiality, a private fork of this project was selected. diff --git a/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js new file mode 100644 index 00000000000..074c311495f --- /dev/null +++ b/spec/frontend/content_editor/components/code_block_bubble_menu_spec.js @@ -0,0 +1,142 @@ +import { BubbleMenu } from '@tiptap/vue-2'; +import { GlButton, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import Vue from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CodeBlockBubbleMenu from '~/content_editor/components/code_block_bubble_menu.vue'; +import eventHubFactory from '~/helpers/event_hub_factory'; +import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import codeBlockLanguageLoader from '~/content_editor/services/code_block_language_loader'; +import { createTestEditor, emitEditorEvent } from '../test_utils'; + +describe('content_editor/components/code_block_bubble_menu', () => { + let wrapper; + let tiptapEditor; + let bubbleMenu; + let eventHub; + + const buildEditor = () => { + tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); + eventHub = eventHubFactory(); + }; + + const buildWrapper = () => { + wrapper = mountExtended(CodeBlockBubbleMenu, { + provide: { + tiptapEditor, + eventHub, + }, + }); + }; + + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownItemsData = () => + findDropdownItems().wrappers.map((x) => ({ + text: x.text(), + visible: x.isVisible(), + checked: x.props('isChecked'), + })); + + beforeEach(() => { + buildEditor(); + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders bubble menu component', async () => { + tiptapEditor.commands.insertContent('

      test
      '); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(bubbleMenu.props('editor')).toBe(tiptapEditor); + expect(bubbleMenu.classes()).toEqual(['gl-shadow', 'gl-rounded-base']); + }); + + it('selects plaintext language by default', async () => { + tiptapEditor.commands.insertContent('
      test
      '); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Plain text'); + }); + + it('selects appropriate language based on the code block', async () => { + tiptapEditor.commands.insertContent('
      var a = 2;
      '); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Javascript'); + }); + + it("selects Custom (syntax) if the language doesn't exist in the list", async () => { + tiptapEditor.commands.insertContent('
      test
      '); + bubbleMenu = wrapper.findComponent(BubbleMenu); + + await emitEditorEvent({ event: 'transaction', tiptapEditor }); + + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Custom (nomnoml)'); + }); + + it('delete button deletes the code block', async () => { + tiptapEditor.commands.insertContent('
      var a = 2;
      '); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(tiptapEditor.getText()).toBe(''); + }); + + describe('when opened and search is changed', () => { + beforeEach(async () => { + tiptapEditor.commands.insertContent('
      var a = 2;
      '); + + wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'js'); + + await Vue.nextTick(); + }); + + it('shows dropdown items', () => { + expect(findDropdownItemsData()).toEqual([ + { text: 'Javascript', visible: true, checked: true }, + { text: 'Java', visible: true, checked: false }, + { text: 'Javascript', visible: false, checked: false }, + { text: 'JSON', visible: true, checked: false }, + ]); + }); + + describe('when dropdown item is clicked', () => { + beforeEach(async () => { + jest.spyOn(codeBlockLanguageLoader, 'loadLanguages').mockResolvedValue(); + + findDropdownItems().at(1).vm.$emit('click'); + + await Vue.nextTick(); + }); + + it('loads language', () => { + expect(codeBlockLanguageLoader.loadLanguages).toHaveBeenCalledWith(['java']); + }); + + it('sets code block', () => { + expect(tiptapEditor.getJSON()).toMatchObject({ + content: [ + { + type: 'codeBlock', + attrs: { + language: 'java', + }, + }, + ], + }); + }); + + it('updates selected dropdown', () => { + expect(wrapper.findComponent(GlDropdown).props('text')).toBe('Java'); + }); + }); + }); +}); diff --git a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js index e44a7fa4ddb..192ddee78c6 100644 --- a/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js +++ b/spec/frontend/content_editor/components/formatting_bubble_menu_spec.js @@ -9,7 +9,7 @@ import { } from '~/content_editor/constants'; import { createTestEditor } from '../test_utils'; -describe('content_editor/components/top_toolbar', () => { +describe('content_editor/components/formatting_bubble_menu', () => { let wrapper; let trackingSpy; let tiptapEditor; diff --git a/spec/frontend/content_editor/components/wrappers/image_spec.js b/spec/frontend/content_editor/components/wrappers/image_spec.js deleted file mode 100644 index 7b057f9cabc..00000000000 --- a/spec/frontend/content_editor/components/wrappers/image_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ImageWrapper from '~/content_editor/components/wrappers/image.vue'; - -describe('content/components/wrappers/image', () => { - let wrapper; - - const createWrapper = async (nodeAttrs = {}) => { - wrapper = shallowMountExtended(ImageWrapper, { - propsData: { - node: { - attrs: nodeAttrs, - }, - }, - }); - }; - const findImage = () => wrapper.findByTestId('image'); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders a node-view-wrapper with display-inline-block class', () => { - createWrapper(); - - expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block'); - }); - - it('renders an image that displays the node src', () => { - const src = 'foobar.png'; - - createWrapper({ src }); - - expect(findImage().attributes().src).toBe(src); - }); - - describe('when uploading', () => { - beforeEach(() => { - createWrapper({ uploading: true }); - }); - - it('renders a gl-loading-icon component', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('adds gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).toContain('gl-opacity-5'); - }); - }); - - describe('when not uploading', () => { - beforeEach(() => { - createWrapper({ uploading: false }); - }); - - it('does not render a gl-loading-icon component', () => { - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('does not add gl-opacity-5 class selector to image', () => { - expect(findImage().classes()).not.toContain('gl-opacity-5'); - }); - }); -}); diff --git a/spec/frontend/content_editor/components/wrappers/media_spec.js b/spec/frontend/content_editor/components/wrappers/media_spec.js new file mode 100644 index 00000000000..3e95e2f3914 --- /dev/null +++ b/spec/frontend/content_editor/components/wrappers/media_spec.js @@ -0,0 +1,69 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import MediaWrapper from '~/content_editor/components/wrappers/media.vue'; + +describe('content/components/wrappers/media', () => { + let wrapper; + + const createWrapper = async (nodeAttrs = {}) => { + wrapper = shallowMountExtended(MediaWrapper, { + propsData: { + node: { + attrs: nodeAttrs, + type: { + name: 'image', + }, + }, + }, + }); + }; + const findMedia = () => wrapper.findByTestId('media'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a node-view-wrapper with display-inline-block class', () => { + createWrapper(); + + expect(wrapper.findComponent(NodeViewWrapper).classes()).toContain('gl-display-inline-block'); + }); + + it('renders an image that displays the node src', () => { + const src = 'foobar.png'; + + createWrapper({ src }); + + expect(findMedia().attributes().src).toBe(src); + }); + + describe('when uploading', () => { + beforeEach(() => { + createWrapper({ uploading: true }); + }); + + it('renders a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('adds gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).toContain('gl-opacity-5'); + }); + }); + + describe('when not uploading', () => { + beforeEach(() => { + createWrapper({ uploading: false }); + }); + + it('does not render a gl-loading-icon component', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('does not add gl-opacity-5 class selector to the media tag', () => { + expect(findMedia().classes()).not.toContain('gl-opacity-5'); + }); + }); +}); diff --git a/spec/frontend/content_editor/extensions/attachment_spec.js b/spec/frontend/content_editor/extensions/attachment_spec.js index ec67545cf17..d3c42104e47 100644 --- a/spec/frontend/content_editor/extensions/attachment_spec.js +++ b/spec/frontend/content_editor/extensions/attachment_spec.js @@ -1,7 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import Attachment from '~/content_editor/extensions/attachment'; import Image from '~/content_editor/extensions/image'; +import Audio from '~/content_editor/extensions/audio'; +import Video from '~/content_editor/extensions/video'; import Link from '~/content_editor/extensions/link'; import Loading from '~/content_editor/extensions/loading'; import { VARIANT_DANGER } from '~/flash'; @@ -14,6 +17,23 @@ const PROJECT_WIKI_ATTACHMENT_IMAGE_HTML = `

      `; + +const PROJECT_WIKI_ATTACHMENT_VIDEO_HTML = `

      + + + test-file + +

      `; + +const PROJECT_WIKI_ATTACHMENT_AUDIO_HTML = `

      + + + test-file + +

      `; + const PROJECT_WIKI_ATTACHMENT_LINK_HTML = `

      test-file

      `; @@ -23,6 +43,8 @@ describe('content_editor/extensions/attachment', () => { let doc; let p; let image; + let audio; + let video; let loading; let link; let renderMarkdown; @@ -31,15 +53,18 @@ describe('content_editor/extensions/attachment', () => { const uploadsPath = '/uploads/'; const imageFile = new File(['foo'], 'test-file.png', { type: 'image/png' }); + const audioFile = new File(['foo'], 'test-file.mp3', { type: 'audio/mpeg' }); + const videoFile = new File(['foo'], 'test-file.mp4', { type: 'video/mp4' }); const attachmentFile = new File(['foo'], 'test-file.zip', { type: 'application/zip' }); const expectDocumentAfterTransaction = ({ number, expectedDoc, action }) => { return new Promise((resolve) => { let counter = 1; - const handleTransaction = () => { + const handleTransaction = async () => { if (counter === number) { expect(tiptapEditor.state.doc.toJSON()).toEqual(expectedDoc.toJSON()); tiptapEditor.off('update', handleTransaction); + await waitForPromises(); resolve(); } @@ -60,18 +85,22 @@ describe('content_editor/extensions/attachment', () => { Loading, Link, Image, + Audio, + Video, Attachment.configure({ renderMarkdown, uploadsPath, eventHub }), ], }); ({ - builders: { doc, p, image, loading, link }, + builders: { doc, p, image, audio, video, loading, link }, } = createDocBuilder({ tiptapEditor, names: { loading: { markType: Loading.name }, image: { nodeType: Image.name }, link: { nodeType: Link.name }, + audio: { nodeType: Audio.name }, + video: { nodeType: Video.name }, }, })); @@ -103,17 +132,22 @@ describe('content_editor/extensions/attachment', () => { tiptapEditor.commands.setContent(initialDoc.toJSON()); }); - describe('when the file has image mime type', () => { - const base64EncodedFile = 'data:image/png;base64,Zm9v'; + describe.each` + nodeType | mimeType | html | file | mediaType + ${'image'} | ${'image/png'} | ${PROJECT_WIKI_ATTACHMENT_IMAGE_HTML} | ${imageFile} | ${(attrs) => image(attrs)} + ${'audio'} | ${'audio/mpeg'} | ${PROJECT_WIKI_ATTACHMENT_AUDIO_HTML} | ${audioFile} | ${(attrs) => audio(attrs)} + ${'video'} | ${'video/mp4'} | ${PROJECT_WIKI_ATTACHMENT_VIDEO_HTML} | ${videoFile} | ${(attrs) => video(attrs)} + `('when the file has $nodeType mime type', ({ mimeType, html, file, mediaType }) => { + const base64EncodedFile = `data:${mimeType};base64,Zm9v`; beforeEach(() => { - renderMarkdown.mockResolvedValue(PROJECT_WIKI_ATTACHMENT_IMAGE_HTML); + renderMarkdown.mockResolvedValue(html); }); describe('when uploading succeeds', () => { const successResponse = { link: { - markdown: '![test-file](test-file.png)', + markdown: `![test-file](${file.name})`, }, }; @@ -121,21 +155,21 @@ describe('content_editor/extensions/attachment', () => { mock.onPost().reply(httpStatus.OK, successResponse); }); - it('inserts an image with src set to the encoded image file and uploading true', async () => { - const expectedDoc = doc(p(image({ uploading: true, src: base64EncodedFile }))); + it('inserts a media content with src set to the encoded content and uploading true', async () => { + const expectedDoc = doc(p(mediaType({ uploading: true, src: base64EncodedFile }))); await expectDocumentAfterTransaction({ number: 1, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('updates the inserted image with canonicalSrc when upload is successful', async () => { + it('updates the inserted content with canonicalSrc when upload is successful', async () => { const expectedDoc = doc( p( - image({ - canonicalSrc: 'test-file.png', + mediaType({ + canonicalSrc: file.name, src: base64EncodedFile, alt: 'test-file', uploading: false, @@ -146,7 +180,7 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); }); @@ -162,17 +196,19 @@ describe('content_editor/extensions/attachment', () => { await expectDocumentAfterTransaction({ number: 2, expectedDoc, - action: () => tiptapEditor.commands.uploadAttachment({ file: imageFile }), + action: () => tiptapEditor.commands.uploadAttachment({ file }), }); }); - it('emits an alert event that includes an error message', (done) => { - tiptapEditor.commands.uploadAttachment({ file: imageFile }); + it('emits an alert event that includes an error message', () => { + tiptapEditor.commands.uploadAttachment({ file }); - eventHub.$on('alert', ({ message, variant }) => { - expect(variant).toBe(VARIANT_DANGER); - expect(message).toBe('An error occurred while uploading the image. Please try again.'); - done(); + return new Promise((resolve) => { + eventHub.$on('alert', ({ message, variant }) => { + expect(variant).toBe(VARIANT_DANGER); + expect(message).toBe('An error occurred while uploading the file. Please try again.'); + resolve(); + }); }); }); }); @@ -243,13 +279,12 @@ describe('content_editor/extensions/attachment', () => { }); }); - it('emits an alert event that includes an error message', (done) => { + it('emits an alert event that includes an error message', () => { tiptapEditor.commands.uploadAttachment({ file: attachmentFile }); eventHub.$on('alert', ({ message, variant }) => { expect(variant).toBe(VARIANT_DANGER); expect(message).toBe('An error occurred while uploading the file. Please try again.'); - done(); }); }); }); diff --git a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js index 05fa0f79ef0..02e5b1dc271 100644 --- a/spec/frontend/content_editor/extensions/code_block_highlight_spec.js +++ b/spec/frontend/content_editor/extensions/code_block_highlight_spec.js @@ -1,5 +1,5 @@ import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; -import { createTestEditor } from '../test_utils'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; const CODE_BLOCK_HTML = `
         
      @@ -12,34 +12,78 @@ const CODE_BLOCK_HTML = `
      +      
      
      +      `,
      +        'text/html',
      +      );
      +
      +      await languageLoader.loadLanguagesFromDOM(body);
      +
      +      expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
      +      expect(lowlight.registerLanguage).toHaveBeenCalledWith('ruby', expect.any(Function));
      +    });
      +  });
      +
      +  describe('loadLanguageFromInputRule', () => {
      +    it('loads highlight.js language packages identified from the input rule', async () => {
      +      const match = new RegExp(backtickInputRegex).exec('```js ');
      +      const attrs = languageLoader.loadLanguageFromInputRule(match);
      +
      +      await waitForPromises();
      +
      +      expect(attrs).toEqual({ language: 'javascript' });
      +      expect(lowlight.registerLanguage).toHaveBeenCalledWith('javascript', expect.any(Function));
      +    });
      +  });
      +
      +  describe('isLanguageLoaded', () => {
      +    it('returns true when a language is registered', async () => {
      +      const language = 'javascript';
      +
      +      expect(languageLoader.isLanguageLoaded(language)).toBe(false);
      +
      +      await languageLoader.loadLanguages([language]);
      +
      +      expect(languageLoader.isLanguageLoaded(language)).toBe(true);
      +    });
      +  });
      +});
      diff --git a/spec/frontend/content_editor/services/content_editor_spec.js b/spec/frontend/content_editor/services/content_editor_spec.js
      index 3bc72b13302..5b7a27b501d 100644
      --- a/spec/frontend/content_editor/services/content_editor_spec.js
      +++ b/spec/frontend/content_editor/services/content_editor_spec.js
      @@ -11,6 +11,7 @@ describe('content_editor/services/content_editor', () => {
         let contentEditor;
         let serializer;
         let deserializer;
      +  let languageLoader;
         let eventHub;
         let doc;
         let p;
      @@ -27,8 +28,15 @@ describe('content_editor/services/content_editor', () => {
       
           serializer = { deserialize: jest.fn() };
           deserializer = { deserialize: jest.fn() };
      +    languageLoader = { loadLanguagesFromDOM: jest.fn() };
           eventHub = eventHubFactory();
      -    contentEditor = new ContentEditor({ tiptapEditor, serializer, deserializer, eventHub });
      +    contentEditor = new ContentEditor({
      +      tiptapEditor,
      +      serializer,
      +      deserializer,
      +      eventHub,
      +      languageLoader,
      +    });
         });
       
         describe('.dispose', () => {
      @@ -43,10 +51,12 @@ describe('content_editor/services/content_editor', () => {
       
         describe('when setSerializedContent succeeds', () => {
           let document;
      +    const dom = {};
      +    const testMarkdown = '**bold text**';
       
           beforeEach(() => {
             document = doc(p('document'));
      -      deserializer.deserialize.mockResolvedValueOnce({ document });
      +      deserializer.deserialize.mockResolvedValueOnce({ document, dom });
           });
       
           it('emits loadingContent and loadingSuccess event in the eventHub', () => {
      @@ -59,14 +69,20 @@ describe('content_editor/services/content_editor', () => {
               expect(loadingContentEmitted).toBe(true);
             });
       
      -      contentEditor.setSerializedContent('**bold text**');
      +      contentEditor.setSerializedContent(testMarkdown);
           });
       
           it('sets the deserialized document in the tiptap editor object', async () => {
      -      await contentEditor.setSerializedContent('**bold text**');
      +      await contentEditor.setSerializedContent(testMarkdown);
       
             expect(contentEditor.tiptapEditor.state.doc.toJSON()).toEqual(document.toJSON());
           });
      +
      +    it('passes deserialized DOM document to language loader', async () => {
      +      await contentEditor.setSerializedContent(testMarkdown);
      +
      +      expect(languageLoader.loadLanguagesFromDOM).toHaveBeenCalledWith(dom);
      +    });
         });
       
         describe('when setSerializedContent fails', () => {
      diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js
      index a4054ab1fc8..ef0ff8ca208 100644
      --- a/spec/frontend/contributors/store/actions_spec.js
      +++ b/spec/frontend/contributors/store/actions_spec.js
      @@ -17,10 +17,14 @@ describe('Contributors store actions', () => {
             mock = new MockAdapter(axios);
           });
       
      -    it('should commit SET_CHART_DATA with received response', (done) => {
      +    afterEach(() => {
      +      mock.restore();
      +    });
      +
      +    it('should commit SET_CHART_DATA with received response', () => {
             mock.onGet().reply(200, chartData);
       
      -      testAction(
      +      return testAction(
               actions.fetchChartData,
               { endpoint },
               {},
      @@ -30,30 +34,22 @@ describe('Contributors store actions', () => {
                 { type: types.SET_LOADING_STATE, payload: false },
               ],
               [],
      -        () => {
      -          mock.restore();
      -          done();
      -        },
             );
           });
       
      -    it('should show flash on API error', (done) => {
      +    it('should show flash on API error', async () => {
             mock.onGet().reply(400, 'Not Found');
       
      -      testAction(
      +      await testAction(
               actions.fetchChartData,
               { endpoint },
               {},
               [{ type: types.SET_LOADING_STATE, payload: true }],
               [],
      -        () => {
      -          expect(createFlash).toHaveBeenCalledWith({
      -            message: expect.stringMatching('error'),
      -          });
      -          mock.restore();
      -          done();
      -        },
             );
      +      expect(createFlash).toHaveBeenCalledWith({
      +        message: expect.stringMatching('error'),
      +      });
           });
         });
       });
      diff --git a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
      index 55c502b96bb..c365cb6a9f4 100644
      --- a/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
      +++ b/spec/frontend/create_cluster/gke_cluster/stores/actions_spec.js
      @@ -14,53 +14,49 @@ import {
       
       describe('GCP Cluster Dropdown Store Actions', () => {
         describe('setProject', () => {
      -    it('should set project', (done) => {
      -      testAction(
      +    it('should set project', () => {
      +      return testAction(
               actions.setProject,
               selectedProjectMock,
               { selectedProject: {} },
               [{ type: 'SET_PROJECT', payload: selectedProjectMock }],
               [],
      -        done,
             );
           });
         });
       
         describe('setZone', () => {
      -    it('should set zone', (done) => {
      -      testAction(
      +    it('should set zone', () => {
      +      return testAction(
               actions.setZone,
               selectedZoneMock,
               { selectedZone: '' },
               [{ type: 'SET_ZONE', payload: selectedZoneMock }],
               [],
      -        done,
             );
           });
         });
       
         describe('setMachineType', () => {
      -    it('should set machine type', (done) => {
      -      testAction(
      +    it('should set machine type', () => {
      +      return testAction(
               actions.setMachineType,
               selectedMachineTypeMock,
               { selectedMachineType: '' },
               [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }],
               [],
      -        done,
             );
           });
         });
       
         describe('setIsValidatingProjectBilling', () => {
      -    it('should set machine type', (done) => {
      -      testAction(
      +    it('should set machine type', () => {
      +      return testAction(
               actions.setIsValidatingProjectBilling,
               true,
               { isValidatingProjectBilling: null },
               [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }],
               [],
      -        done,
             );
           });
         });
      @@ -94,8 +90,8 @@ describe('GCP Cluster Dropdown Store Actions', () => {
           });
       
           describe('validateProjectBilling', () => {
      -      it('checks project billing status from Google API', (done) => {
      -        testAction(
      +      it('checks project billing status from Google API', () => {
      +        return testAction(
                 actions.validateProjectBilling,
                 true,
                 {
      @@ -110,7 +106,6 @@ describe('GCP Cluster Dropdown Store Actions', () => {
                   { type: 'SET_PROJECT_BILLING_STATUS', payload: true },
                 ],
                 [{ type: 'setIsValidatingProjectBilling', payload: false }],
      -          done,
               );
             });
           });
      diff --git a/spec/frontend/crm/contact_form_spec.js b/spec/frontend/crm/contact_form_spec.js
      deleted file mode 100644
      index 0edab4f5ec5..00000000000
      --- a/spec/frontend/crm/contact_form_spec.js
      +++ /dev/null
      @@ -1,157 +0,0 @@
      -import { GlAlert } from '@gitlab/ui';
      -import Vue from 'vue';
      -import VueApollo from 'vue-apollo';
      -import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
      -import createMockApollo from 'helpers/mock_apollo_helper';
      -import waitForPromises from 'helpers/wait_for_promises';
      -import ContactForm from '~/crm/components/contact_form.vue';
      -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
      -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
      -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
      -import {
      -  createContactMutationErrorResponse,
      -  createContactMutationResponse,
      -  getGroupContactsQueryResponse,
      -  updateContactMutationErrorResponse,
      -  updateContactMutationResponse,
      -} from './mock_data';
      -
      -describe('Customer relations contact form component', () => {
      -  Vue.use(VueApollo);
      -  let wrapper;
      -  let fakeApollo;
      -  let mutation;
      -  let queryHandler;
      -
      -  const findSaveContactButton = () => wrapper.findByTestId('save-contact-button');
      -  const findCancelButton = () => wrapper.findByTestId('cancel-button');
      -  const findForm = () => wrapper.find('form');
      -  const findError = () => wrapper.findComponent(GlAlert);
      -
      -  const mountComponent = ({ mountFunction = shallowMountExtended, editForm = false } = {}) => {
      -    fakeApollo = createMockApollo([[mutation, queryHandler]]);
      -    fakeApollo.clients.defaultClient.cache.writeQuery({
      -      query: getGroupContactsQuery,
      -      variables: { groupFullPath: 'flightjs' },
      -      data: getGroupContactsQueryResponse.data,
      -    });
      -    const propsData = { drawerOpen: true };
      -    if (editForm)
      -      propsData.contact = { firstName: 'First', lastName: 'Last', email: 'email@example.com' };
      -    wrapper = mountFunction(ContactForm, {
      -      provide: { groupId: 26, groupFullPath: 'flightjs' },
      -      apolloProvider: fakeApollo,
      -      propsData,
      -    });
      -  };
      -
      -  beforeEach(() => {
      -    mutation = createContactMutation;
      -    queryHandler = jest.fn().mockResolvedValue(createContactMutationResponse);
      -  });
      -
      -  afterEach(() => {
      -    wrapper.destroy();
      -    fakeApollo = null;
      -  });
      -
      -  describe('Save contact button', () => {
      -    it('should be disabled when required fields are empty', () => {
      -      mountComponent();
      -
      -      expect(findSaveContactButton().props('disabled')).toBe(true);
      -    });
      -
      -    it('should not be disabled when required fields have values', async () => {
      -      mountComponent();
      -
      -      wrapper.find('#contact-first-name').vm.$emit('input', 'A');
      -      wrapper.find('#contact-last-name').vm.$emit('input', 'B');
      -      wrapper.find('#contact-email').vm.$emit('input', 'C');
      -      await waitForPromises();
      -
      -      expect(findSaveContactButton().props('disabled')).toBe(false);
      -    });
      -  });
      -
      -  it("should emit 'close' when cancel button is clicked", () => {
      -    mountComponent();
      -
      -    findCancelButton().vm.$emit('click');
      -
      -    expect(wrapper.emitted().close).toBeTruthy();
      -  });
      -
      -  describe('when create mutation is successful', () => {
      -    it("should emit 'close'", async () => {
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(wrapper.emitted().close).toBeTruthy();
      -    });
      -  });
      -
      -  describe('when create mutation fails', () => {
      -    it('should show error on reject', async () => {
      -      queryHandler = jest.fn().mockRejectedValue('ERROR');
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -    });
      -
      -    it('should show error on error response', async () => {
      -      queryHandler = jest.fn().mockResolvedValue(createContactMutationErrorResponse);
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -      expect(findError().text()).toBe('create contact is invalid.');
      -    });
      -  });
      -
      -  describe('when update mutation is successful', () => {
      -    it("should emit 'close'", async () => {
      -      mutation = updateContactMutation;
      -      queryHandler = jest.fn().mockResolvedValue(updateContactMutationResponse);
      -      mountComponent({ editForm: true });
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(wrapper.emitted().close).toBeTruthy();
      -    });
      -  });
      -
      -  describe('when update mutation fails', () => {
      -    beforeEach(() => {
      -      mutation = updateContactMutation;
      -    });
      -
      -    it('should show error on reject', async () => {
      -      queryHandler = jest.fn().mockRejectedValue('ERROR');
      -      mountComponent({ editForm: true });
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -    });
      -
      -    it('should show error on error response', async () => {
      -      queryHandler = jest.fn().mockResolvedValue(updateContactMutationErrorResponse);
      -      mountComponent({ editForm: true });
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -      expect(findError().text()).toBe('update contact is invalid.');
      -    });
      -  });
      -});
      diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js
      new file mode 100644
      index 00000000000..6307889a7aa
      --- /dev/null
      +++ b/spec/frontend/crm/contact_form_wrapper_spec.js
      @@ -0,0 +1,88 @@
      +import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
      +import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue';
      +import ContactForm from '~/crm/components/form.vue';
      +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
      +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
      +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
      +
      +describe('Customer relations contact form wrapper', () => {
      +  let wrapper;
      +
      +  const findContactForm = () => wrapper.findComponent(ContactForm);
      +
      +  const $apollo = {
      +    queries: {
      +      contacts: {
      +        loading: false,
      +      },
      +    },
      +  };
      +  const $route = {
      +    params: {
      +      id: 7,
      +    },
      +  };
      +  const contacts = [{ id: 'gid://gitlab/CustomerRelations::Contact/7' }];
      +
      +  const mountComponent = ({ isEditMode = false } = {}) => {
      +    wrapper = shallowMountExtended(ContactFormWrapper, {
      +      propsData: {
      +        isEditMode,
      +      },
      +      provide: {
      +        groupFullPath: 'flightjs',
      +        groupId: 26,
      +      },
      +      mocks: {
      +        $apollo,
      +        $route,
      +      },
      +    });
      +  };
      +
      +  afterEach(() => {
      +    wrapper.destroy();
      +  });
      +
      +  describe('in edit mode', () => {
      +    it('should render contact form with correct props', () => {
      +      mountComponent({ isEditMode: true });
      +
      +      const contactForm = findContactForm();
      +      expect(contactForm.props('fields')).toHaveLength(5);
      +      expect(contactForm.props('title')).toBe('Edit contact');
      +      expect(contactForm.props('successMessage')).toBe('Contact has been updated.');
      +      expect(contactForm.props('mutation')).toBe(updateContactMutation);
      +      expect(contactForm.props('getQuery')).toMatchObject({
      +        query: getGroupContactsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      });
      +      expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
      +      expect(contactForm.props('existingId')).toBe(contacts[0].id);
      +      expect(contactForm.props('additionalCreateParams')).toMatchObject({
      +        groupId: 'gid://gitlab/Group/26',
      +      });
      +    });
      +  });
      +
      +  describe('in create mode', () => {
      +    it('should render contact form with correct props', () => {
      +      mountComponent();
      +
      +      const contactForm = findContactForm();
      +      expect(contactForm.props('fields')).toHaveLength(5);
      +      expect(contactForm.props('title')).toBe('New contact');
      +      expect(contactForm.props('successMessage')).toBe('Contact has been added.');
      +      expect(contactForm.props('mutation')).toBe(createContactMutation);
      +      expect(contactForm.props('getQuery')).toMatchObject({
      +        query: getGroupContactsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      });
      +      expect(contactForm.props('getQueryNodePath')).toBe('group.contacts');
      +      expect(contactForm.props('existingId')).toBeNull();
      +      expect(contactForm.props('additionalCreateParams')).toMatchObject({
      +        groupId: 'gid://gitlab/Group/26',
      +      });
      +    });
      +  });
      +});
      diff --git a/spec/frontend/crm/contacts_root_spec.js b/spec/frontend/crm/contacts_root_spec.js
      index b30349305a3..b02d94e9cb1 100644
      --- a/spec/frontend/crm/contacts_root_spec.js
      +++ b/spec/frontend/crm/contacts_root_spec.js
      @@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
       import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
       import createMockApollo from 'helpers/mock_apollo_helper';
       import waitForPromises from 'helpers/wait_for_promises';
      -import ContactsRoot from '~/crm/components/contacts_root.vue';
      -import ContactForm from '~/crm/components/contact_form.vue';
      -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
      -import { NEW_ROUTE_NAME, EDIT_ROUTE_NAME } from '~/crm/constants';
      -import routes from '~/crm/routes';
      +import ContactsRoot from '~/crm/contacts/components/contacts_root.vue';
      +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
      +import routes from '~/crm/contacts/routes';
       import { getGroupContactsQueryResponse } from './mock_data';
       
       describe('Customer relations contacts root app', () => {
      @@ -23,8 +21,6 @@ describe('Customer relations contacts root app', () => {
         const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
         const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
         const findNewContactButton = () => wrapper.findByTestId('new-contact-button');
      -  const findEditContactButton = () => wrapper.findByTestId('edit-contact-button');
      -  const findContactForm = () => wrapper.findComponent(ContactForm);
         const findError = () => wrapper.findComponent(GlAlert);
         const successQueryHandler = jest.fn().mockResolvedValue(getGroupContactsQueryResponse);
       
      @@ -40,8 +36,8 @@ describe('Customer relations contacts root app', () => {
             router,
             provide: {
               groupFullPath: 'flightjs',
      -        groupIssuesPath: '/issues',
               groupId: 26,
      +        groupIssuesPath: '/issues',
               canAdminCrmContact,
             },
             apolloProvider: fakeApollo,
      @@ -82,71 +78,6 @@ describe('Customer relations contacts root app', () => {
           });
         });
       
      -  describe('contact form', () => {
      -    it('should not exist by default', async () => {
      -      mountComponent();
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(false);
      -    });
      -
      -    it('should exist when user clicks new contact button', async () => {
      -      mountComponent();
      -
      -      findNewContactButton().vm.$emit('click');
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(true);
      -    });
      -
      -    it('should exist when user navigates directly to `new` route', async () => {
      -      router.replace({ name: NEW_ROUTE_NAME });
      -      mountComponent();
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(true);
      -    });
      -
      -    it('should exist when user clicks edit contact button', async () => {
      -      mountComponent({ mountFunction: mountExtended });
      -      await waitForPromises();
      -
      -      findEditContactButton().vm.$emit('click');
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(true);
      -    });
      -
      -    it('should exist when user navigates directly to `edit` route', async () => {
      -      router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
      -      mountComponent();
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(true);
      -    });
      -
      -    it('should not exist when new form emits close', async () => {
      -      router.replace({ name: NEW_ROUTE_NAME });
      -      mountComponent();
      -
      -      findContactForm().vm.$emit('close');
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(false);
      -    });
      -
      -    it('should not exist when edit form emits close', async () => {
      -      router.replace({ name: EDIT_ROUTE_NAME, params: { id: 16 } });
      -      mountComponent();
      -      await waitForPromises();
      -
      -      findContactForm().vm.$emit('close');
      -      await waitForPromises();
      -
      -      expect(findContactForm().exists()).toBe(false);
      -    });
      -  });
      -
         describe('error', () => {
           it('should exist on reject', async () => {
             mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
      diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/form_spec.js
      index 0e3abc05c37..5c349b24ea1 100644
      --- a/spec/frontend/crm/form_spec.js
      +++ b/spec/frontend/crm/form_spec.js
      @@ -6,12 +6,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
       import createMockApollo from 'helpers/mock_apollo_helper';
       import waitForPromises from 'helpers/wait_for_promises';
       import Form from '~/crm/components/form.vue';
      -import routes from '~/crm/routes';
      -import createContactMutation from '~/crm/components/queries/create_contact.mutation.graphql';
      -import updateContactMutation from '~/crm/components/queries/update_contact.mutation.graphql';
      -import getGroupContactsQuery from '~/crm/components/queries/get_group_contacts.query.graphql';
      -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
      -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
      +import routes from '~/crm/contacts/routes';
      +import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql';
      +import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql';
      +import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql';
      +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
      +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
       import {
         createContactMutationErrorResponse,
         createContactMutationResponse,
      @@ -101,6 +101,11 @@ describe('Reusable form component', () => {
               { name: 'phone', label: 'Phone' },
               { name: 'description', label: 'Description' },
             ],
      +      getQuery: {
      +        query: getGroupContactsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      },
      +      getQueryNodePath: 'group.contacts',
             ...propsData,
           });
         };
      @@ -108,13 +113,8 @@ describe('Reusable form component', () => {
         const mountContactCreate = () => {
           const propsData = {
             title: 'New contact',
      -      successMessage: 'Contact has been added',
      +      successMessage: 'Contact has been added.',
             buttonLabel: 'Create contact',
      -      getQuery: {
      -        query: getGroupContactsQuery,
      -        variables: { groupFullPath: 'flightjs' },
      -      },
      -      getQueryNodePath: 'group.contacts',
             mutation: createContactMutation,
             additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
           };
      @@ -124,14 +124,9 @@ describe('Reusable form component', () => {
         const mountContactUpdate = () => {
           const propsData = {
             title: 'Edit contact',
      -      successMessage: 'Contact has been updated',
      +      successMessage: 'Contact has been updated.',
             mutation: updateContactMutation,
      -      existingModel: {
      -        id: 'gid://gitlab/CustomerRelations::Contact/12',
      -        firstName: 'First',
      -        lastName: 'Last',
      -        email: 'email@example.com',
      -      },
      +      existingId: 'gid://gitlab/CustomerRelations::Contact/12',
           };
           mountContact({ propsData });
         };
      @@ -143,6 +138,11 @@ describe('Reusable form component', () => {
               { name: 'defaultRate', label: 'Default rate', input: { type: 'number', step: '0.01' } },
               { name: 'description', label: 'Description' },
             ],
      +      getQuery: {
      +        query: getGroupOrganizationsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      },
      +      getQueryNodePath: 'group.organizations',
             ...propsData,
           });
         };
      @@ -150,13 +150,8 @@ describe('Reusable form component', () => {
         const mountOrganizationCreate = () => {
           const propsData = {
             title: 'New organization',
      -      successMessage: 'Organization has been added',
      +      successMessage: 'Organization has been added.',
             buttonLabel: 'Create organization',
      -      getQuery: {
      -        query: getGroupOrganizationsQuery,
      -        variables: { groupFullPath: 'flightjs' },
      -      },
      -      getQueryNodePath: 'group.organizations',
             mutation: createOrganizationMutation,
             additionalCreateParams: { groupId: 'gid://gitlab/Group/26' },
           };
      @@ -167,17 +162,17 @@ describe('Reusable form component', () => {
           [FORM_CREATE_CONTACT]: {
             mountFunction: mountContactCreate,
             mutationErrorResponse: createContactMutationErrorResponse,
      -      toastMessage: 'Contact has been added',
      +      toastMessage: 'Contact has been added.',
           },
           [FORM_UPDATE_CONTACT]: {
             mountFunction: mountContactUpdate,
             mutationErrorResponse: updateContactMutationErrorResponse,
      -      toastMessage: 'Contact has been updated',
      +      toastMessage: 'Contact has been updated.',
           },
           [FORM_CREATE_ORG]: {
             mountFunction: mountOrganizationCreate,
             mutationErrorResponse: createOrganizationMutationErrorResponse,
      -      toastMessage: 'Organization has been added',
      +      toastMessage: 'Organization has been added.',
           },
         };
         const asTestParams = (...keys) => keys.map((name) => [name, forms[name]]);
      diff --git a/spec/frontend/crm/mock_data.js b/spec/frontend/crm/mock_data.js
      index e351e101b29..35bc7fb69b4 100644
      --- a/spec/frontend/crm/mock_data.js
      +++ b/spec/frontend/crm/mock_data.js
      @@ -157,3 +157,28 @@ export const createOrganizationMutationErrorResponse = {
           },
         },
       };
      +
      +export const updateOrganizationMutationResponse = {
      +  data: {
      +    customerRelationsOrganizationUpdate: {
      +      __typeName: 'CustomerRelationsOrganizationUpdatePayload',
      +      organization: {
      +        __typename: 'CustomerRelationsOrganization',
      +        id: 'gid://gitlab/CustomerRelations::Organization/2',
      +        name: 'A',
      +        defaultRate: null,
      +        description: null,
      +      },
      +      errors: [],
      +    },
      +  },
      +};
      +
      +export const updateOrganizationMutationErrorResponse = {
      +  data: {
      +    customerRelationsOrganizationUpdate: {
      +      organization: null,
      +      errors: ['Description is invalid.'],
      +    },
      +  },
      +};
      diff --git a/spec/frontend/crm/new_organization_form_spec.js b/spec/frontend/crm/new_organization_form_spec.js
      deleted file mode 100644
      index 0a7909774c9..00000000000
      --- a/spec/frontend/crm/new_organization_form_spec.js
      +++ /dev/null
      @@ -1,109 +0,0 @@
      -import { GlAlert } from '@gitlab/ui';
      -import Vue from 'vue';
      -import VueApollo from 'vue-apollo';
      -import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
      -import createMockApollo from 'helpers/mock_apollo_helper';
      -import waitForPromises from 'helpers/wait_for_promises';
      -import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
      -import createOrganizationMutation from '~/crm/components/queries/create_organization.mutation.graphql';
      -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
      -import {
      -  createOrganizationMutationErrorResponse,
      -  createOrganizationMutationResponse,
      -  getGroupOrganizationsQueryResponse,
      -} from './mock_data';
      -
      -describe('Customer relations organizations root app', () => {
      -  Vue.use(VueApollo);
      -  let wrapper;
      -  let fakeApollo;
      -  let queryHandler;
      -
      -  const findCreateNewOrganizationButton = () =>
      -    wrapper.findByTestId('create-new-organization-button');
      -  const findCancelButton = () => wrapper.findByTestId('cancel-button');
      -  const findForm = () => wrapper.find('form');
      -  const findError = () => wrapper.findComponent(GlAlert);
      -
      -  const mountComponent = () => {
      -    fakeApollo = createMockApollo([[createOrganizationMutation, queryHandler]]);
      -    fakeApollo.clients.defaultClient.cache.writeQuery({
      -      query: getGroupOrganizationsQuery,
      -      variables: { groupFullPath: 'flightjs' },
      -      data: getGroupOrganizationsQueryResponse.data,
      -    });
      -    wrapper = shallowMountExtended(NewOrganizationForm, {
      -      provide: { groupId: 26, groupFullPath: 'flightjs' },
      -      apolloProvider: fakeApollo,
      -      propsData: { drawerOpen: true },
      -    });
      -  };
      -
      -  beforeEach(() => {
      -    queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationResponse);
      -  });
      -
      -  afterEach(() => {
      -    wrapper.destroy();
      -    fakeApollo = null;
      -  });
      -
      -  describe('Create new organization button', () => {
      -    it('should be disabled by default', () => {
      -      mountComponent();
      -
      -      expect(findCreateNewOrganizationButton().attributes('disabled')).toBeTruthy();
      -    });
      -
      -    it('should not be disabled when first, last and email have values', async () => {
      -      mountComponent();
      -
      -      wrapper.find('#organization-name').vm.$emit('input', 'A');
      -      await waitForPromises();
      -
      -      expect(findCreateNewOrganizationButton().attributes('disabled')).toBeFalsy();
      -    });
      -  });
      -
      -  it("should emit 'close' when cancel button is clicked", () => {
      -    mountComponent();
      -
      -    findCancelButton().vm.$emit('click');
      -
      -    expect(wrapper.emitted().close).toBeTruthy();
      -  });
      -
      -  describe('when query is successful', () => {
      -    it("should emit 'close'", async () => {
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(wrapper.emitted().close).toBeTruthy();
      -    });
      -  });
      -
      -  describe('when query fails', () => {
      -    it('should show error on reject', async () => {
      -      queryHandler = jest.fn().mockRejectedValue('ERROR');
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -    });
      -
      -    it('should show error on error response', async () => {
      -      queryHandler = jest.fn().mockResolvedValue(createOrganizationMutationErrorResponse);
      -      mountComponent();
      -
      -      findForm().trigger('submit');
      -      await waitForPromises();
      -
      -      expect(findError().exists()).toBe(true);
      -      expect(findError().text()).toBe('create organization is invalid.');
      -    });
      -  });
      -});
      diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js
      new file mode 100644
      index 00000000000..1a5a7c6ca5d
      --- /dev/null
      +++ b/spec/frontend/crm/organization_form_wrapper_spec.js
      @@ -0,0 +1,88 @@
      +import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
      +import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue';
      +import OrganizationForm from '~/crm/components/form.vue';
      +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
      +import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql';
      +import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql';
      +
      +describe('Customer relations organization form wrapper', () => {
      +  let wrapper;
      +
      +  const findOrganizationForm = () => wrapper.findComponent(OrganizationForm);
      +
      +  const $apollo = {
      +    queries: {
      +      organizations: {
      +        loading: false,
      +      },
      +    },
      +  };
      +  const $route = {
      +    params: {
      +      id: 7,
      +    },
      +  };
      +  const organizations = [{ id: 'gid://gitlab/CustomerRelations::Organization/7' }];
      +
      +  const mountComponent = ({ isEditMode = false } = {}) => {
      +    wrapper = shallowMountExtended(OrganizationFormWrapper, {
      +      propsData: {
      +        isEditMode,
      +      },
      +      provide: {
      +        groupFullPath: 'flightjs',
      +        groupId: 26,
      +      },
      +      mocks: {
      +        $apollo,
      +        $route,
      +      },
      +    });
      +  };
      +
      +  afterEach(() => {
      +    wrapper.destroy();
      +  });
      +
      +  describe('in edit mode', () => {
      +    it('should render organization form with correct props', () => {
      +      mountComponent({ isEditMode: true });
      +
      +      const organizationForm = findOrganizationForm();
      +      expect(organizationForm.props('fields')).toHaveLength(3);
      +      expect(organizationForm.props('title')).toBe('Edit organization');
      +      expect(organizationForm.props('successMessage')).toBe('Organization has been updated.');
      +      expect(organizationForm.props('mutation')).toBe(updateOrganizationMutation);
      +      expect(organizationForm.props('getQuery')).toMatchObject({
      +        query: getGroupOrganizationsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      });
      +      expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
      +      expect(organizationForm.props('existingId')).toBe(organizations[0].id);
      +      expect(organizationForm.props('additionalCreateParams')).toMatchObject({
      +        groupId: 'gid://gitlab/Group/26',
      +      });
      +    });
      +  });
      +
      +  describe('in create mode', () => {
      +    it('should render organization form with correct props', () => {
      +      mountComponent();
      +
      +      const organizationForm = findOrganizationForm();
      +      expect(organizationForm.props('fields')).toHaveLength(3);
      +      expect(organizationForm.props('title')).toBe('New organization');
      +      expect(organizationForm.props('successMessage')).toBe('Organization has been added.');
      +      expect(organizationForm.props('mutation')).toBe(createOrganizationMutation);
      +      expect(organizationForm.props('getQuery')).toMatchObject({
      +        query: getGroupOrganizationsQuery,
      +        variables: { groupFullPath: 'flightjs' },
      +      });
      +      expect(organizationForm.props('getQueryNodePath')).toBe('group.organizations');
      +      expect(organizationForm.props('existingId')).toBeNull();
      +      expect(organizationForm.props('additionalCreateParams')).toMatchObject({
      +        groupId: 'gid://gitlab/Group/26',
      +      });
      +    });
      +  });
      +});
      diff --git a/spec/frontend/crm/organizations_root_spec.js b/spec/frontend/crm/organizations_root_spec.js
      index aef417964f4..231208d938e 100644
      --- a/spec/frontend/crm/organizations_root_spec.js
      +++ b/spec/frontend/crm/organizations_root_spec.js
      @@ -5,11 +5,9 @@ import VueRouter from 'vue-router';
       import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
       import createMockApollo from 'helpers/mock_apollo_helper';
       import waitForPromises from 'helpers/wait_for_promises';
      -import OrganizationsRoot from '~/crm/components/organizations_root.vue';
      -import NewOrganizationForm from '~/crm/components/new_organization_form.vue';
      -import { NEW_ROUTE_NAME } from '~/crm/constants';
      -import routes from '~/crm/routes';
      -import getGroupOrganizationsQuery from '~/crm/components/queries/get_group_organizations.query.graphql';
      +import OrganizationsRoot from '~/crm/organizations/components/organizations_root.vue';
      +import routes from '~/crm/organizations/routes';
      +import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql';
       import { getGroupOrganizationsQueryResponse } from './mock_data';
       
       describe('Customer relations organizations root app', () => {
      @@ -23,7 +21,6 @@ describe('Customer relations organizations root app', () => {
         const findRowByName = (rowName) => wrapper.findAllByRole('row', { name: rowName });
         const findIssuesLinks = () => wrapper.findAllByTestId('issues-link');
         const findNewOrganizationButton = () => wrapper.findByTestId('new-organization-button');
      -  const findNewOrganizationForm = () => wrapper.findComponent(NewOrganizationForm);
         const findError = () => wrapper.findComponent(GlAlert);
         const successQueryHandler = jest.fn().mockResolvedValue(getGroupOrganizationsQueryResponse);
       
      @@ -37,7 +34,11 @@ describe('Customer relations organizations root app', () => {
           fakeApollo = createMockApollo([[getGroupOrganizationsQuery, queryHandler]]);
           wrapper = mountFunction(OrganizationsRoot, {
             router,
      -      provide: { canAdminCrmOrganization, groupFullPath: 'flightjs', groupIssuesPath: '/issues' },
      +      provide: {
      +        canAdminCrmOrganization,
      +        groupFullPath: 'flightjs',
      +        groupIssuesPath: '/issues',
      +      },
             apolloProvider: fakeApollo,
           });
         };
      @@ -76,42 +77,6 @@ describe('Customer relations organizations root app', () => {
           });
         });
       
      -  describe('new organization form', () => {
      -    it('should not exist by default', async () => {
      -      mountComponent();
      -      await waitForPromises();
      -
      -      expect(findNewOrganizationForm().exists()).toBe(false);
      -    });
      -
      -    it('should exist when user clicks new contact button', async () => {
      -      mountComponent();
      -
      -      findNewOrganizationButton().vm.$emit('click');
      -      await waitForPromises();
      -
      -      expect(findNewOrganizationForm().exists()).toBe(true);
      -    });
      -
      -    it('should exist when user navigates directly to /new', async () => {
      -      router.replace({ name: NEW_ROUTE_NAME });
      -      mountComponent();
      -      await waitForPromises();
      -
      -      expect(findNewOrganizationForm().exists()).toBe(true);
      -    });
      -
      -    it('should not exist when form emits close', async () => {
      -      router.replace({ name: NEW_ROUTE_NAME });
      -      mountComponent();
      -
      -      findNewOrganizationForm().vm.$emit('close');
      -      await waitForPromises();
      -
      -      expect(findNewOrganizationForm().exists()).toBe(false);
      -    });
      -  });
      -
         it('should render error message on reject', async () => {
           mountComponent({ queryHandler: jest.fn().mockRejectedValue('ERROR') });
           await waitForPromises();
      diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
      index 4ecf82a4714..402e55347af 100644
      --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
      +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
      @@ -5,16 +5,19 @@ exports[`Design note component should match the snapshot 1`] = `
         class="design-note note-form"
         id="note_123"
       >
      -  
      +  
      +    
      +  
          
         
      diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index bbf2190ad47..77935fbde11 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -31,7 +31,6 @@ describe('Design discussions component', () => { const findReplyForm = () => wrapper.find(DesignReplyForm); const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); - const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); @@ -117,7 +116,7 @@ describe('Design discussions component', () => { }); it('does not render an icon to resolve a thread', () => { - expect(findResolveIcon().exists()).toBe(false); + expect(findResolveButton().exists()).toBe(false); }); it('does not render a checkbox in reply form', async () => { @@ -147,7 +146,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle'); + expect(findResolveButton().props('icon')).toBe('check-circle'); }); it('renders a checkbox with Resolve thread text in reply form', async () => { @@ -203,7 +202,7 @@ describe('Design discussions component', () => { }); it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle-filled'); + expect(findResolveButton().props('icon')).toBe('check-circle-filled'); }); it('emit todo:toggle when discussion is resolved', async () => { diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 35fd1273270..1f84fde9f7f 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -1,10 +1,10 @@ -import { shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; import { nextTick } from 'vue'; +import { GlAvatar, GlAvatarLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DesignNote from '~/design_management/components/design_notes/design_note.vue'; import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; const scrollIntoViewMock = jest.fn(); const note = { @@ -12,6 +12,8 @@ const note = { author: { id: 'gid://gitlab/User/1', username: 'foo-bar', + avatarUrl: 'https://gitlab.com/avatar', + webUrl: 'https://gitlab.com/user', }, body: 'test', userPermissions: { @@ -30,14 +32,15 @@ const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); describe('Design note component', () => { let wrapper; - const findUserAvatar = () => wrapper.find(UserAvatarLink); - const findUserLink = () => wrapper.find('.js-user-link'); - const findReplyForm = () => wrapper.find(DesignReplyForm); - const findEditButton = () => wrapper.find('.js-note-edit'); - const findNoteContent = () => wrapper.find('.js-note-text'); + const findUserAvatar = () => wrapper.findComponent(GlAvatar); + const findUserAvatarLink = () => wrapper.findComponent(GlAvatarLink); + const findUserLink = () => wrapper.findByTestId('user-link'); + const findReplyForm = () => wrapper.findComponent(DesignReplyForm); + const findEditButton = () => wrapper.findByTestId('note-edit'); + const findNoteContent = () => wrapper.findByTestId('note-text'); function createComponent(props = {}, data = { isEditing: false }) { - wrapper = shallowMount(DesignNote, { + wrapper = shallowMountExtended(DesignNote, { propsData: { note: {}, ...props, @@ -71,12 +74,24 @@ describe('Design note component', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('should render an author', () => { + it('should render avatar with correct props', () => { + createComponent({ + note, + }); + + expect(findUserAvatar().props()).toMatchObject({ + src: note.author.avatarUrl, + entityName: note.author.username, + }); + + expect(findUserAvatarLink().attributes('href')).toBe(note.author.webUrl); + }); + + it('should render author details', () => { createComponent({ note, }); - expect(findUserAvatar().exists()).toBe(true); expect(findUserLink().exists()).toBe(true); }); @@ -107,7 +122,7 @@ describe('Design note component', () => { }, }); - findEditButton().trigger('click'); + findEditButton().vm.$emit('click'); await nextTick(); expect(findReplyForm().exists()).toBe(true); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index a240a41959f..87531e8b645 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -183,7 +183,7 @@ describe('Design management index page', () => { [moveDesignMutation, moveDesignHandler], ]; - fakeApollo = createMockApollo(requestHandlers); + fakeApollo = createMockApollo(requestHandlers, {}, { addTypename: true }); wrapper = shallowMount(Index, { apolloProvider: fakeApollo, router, diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index d887029124f..eee17e118a0 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -11,7 +11,9 @@ jest.mock('~/user_popovers'); const TEST_AUTHOR_NAME = 'test'; const TEST_AUTHOR_EMAIL = 'test+test@gitlab.com'; const TEST_AUTHOR_GRAVATAR = `${TEST_HOST}/avatar/test?s=40`; -const TEST_SIGNATURE_HTML = 'Legit commit'; +const TEST_SIGNATURE_HTML = ` + Verified +`; const TEST_PIPELINE_STATUS_PATH = `${TEST_HOST}/pipeline/status`; describe('diffs/components/commit_item', () => { @@ -82,7 +84,7 @@ describe('diffs/components/commit_item', () => { const imgElement = avatarElement.find('img'); expect(avatarElement.attributes('href')).toBe(commit.author.web_url); - expect(imgElement.classes()).toContain('s40'); + expect(imgElement.classes()).toContain('s32'); expect(imgElement.attributes('alt')).toBe(commit.author.name); expect(imgElement.attributes('src')).toBe(commit.author.avatar_url); }); @@ -156,8 +158,9 @@ describe('diffs/components/commit_item', () => { it('renders signature html', () => { const actionsElement = getCommitActionsElement(); + const signatureElement = actionsElement.find('.gpg-status-box'); - expect(actionsElement.html()).toContain(TEST_SIGNATURE_HTML); + expect(signatureElement.html()).toBe(TEST_SIGNATURE_HTML); }); }); diff --git a/spec/frontend/diffs/components/diff_line_note_form_spec.js b/spec/frontend/diffs/components/diff_line_note_form_spec.js index 0ccf996e220..fb9dc22ce25 100644 --- a/spec/frontend/diffs/components/diff_line_note_form_spec.js +++ b/spec/frontend/diffs/components/diff_line_note_form_spec.js @@ -4,7 +4,7 @@ import DiffLineNoteForm from '~/diffs/components/diff_line_note_form.vue'; import { createStore } from '~/mr_notes/stores'; import NoteForm from '~/notes/components/note_form.vue'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal', () => { @@ -98,7 +98,7 @@ describe('DiffLineNoteForm', () => { }); describe('saveNoteForm', () => { - it('should call saveNote action with proper params', (done) => { + it('should call saveNote action with proper params', async () => { const saveDiffDiscussionSpy = jest .spyOn(wrapper.vm, 'saveDiffDiscussion') .mockReturnValue(Promise.resolve()); @@ -123,16 +123,11 @@ describe('DiffLineNoteForm', () => { lineRange, }; - wrapper.vm - .handleSaveNote('note body') - .then(() => { - expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ - note: 'note body', - formData, - }); - }) - .then(done) - .catch(done.fail); + await wrapper.vm.handleSaveNote('note body'); + expect(saveDiffDiscussionSpy).toHaveBeenCalledWith({ + note: 'note body', + formData, + }); }); }); }); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index d6a2aa104cd..3b567fbc704 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -9,46 +9,7 @@ import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE, } from '~/diffs/constants'; -import { - setBaseConfig, - fetchDiffFilesBatch, - fetchDiffFilesMeta, - fetchCoverageFiles, - assignDiscussionsToDiff, - removeDiscussionsFromDiff, - startRenderDiffsQueue, - setInlineDiffViewType, - setParallelDiffViewType, - showCommentForm, - cancelCommentForm, - loadMoreLines, - scrollToLineIfNeededInline, - scrollToLineIfNeededParallel, - loadCollapsedDiff, - toggleFileDiscussions, - saveDiffDiscussion, - setHighlightedRow, - toggleTreeOpen, - scrollToFile, - setShowTreeList, - renderFileForDiscussionId, - setRenderTreeList, - setShowWhitespace, - setRenderIt, - receiveFullDiffError, - fetchFullDiff, - toggleFullDiff, - switchToFullDiffFromRenamedFile, - setFileCollapsedByUser, - setExpandedDiffLines, - setSuggestPopoverDismissed, - changeCurrentCommit, - moveToNeighboringCommit, - setCurrentDiffFileIdFromNote, - navigateToDiffFileIndex, - setFileByFile, - reviewFile, -} from '~/diffs/store/actions'; +import * as diffActions from '~/diffs/store/actions'; import * as types from '~/diffs/store/mutation_types'; import * as utils from '~/diffs/store/utils'; import * as treeWorkerUtils from '~/diffs/utils/tree_worker_utils'; @@ -62,6 +23,8 @@ import { diffMetadata } from '../mock_data/diff_metadata'; jest.mock('~/flash'); describe('DiffsStoreActions', () => { + let mock; + useLocalStorageSpy(); const originalMethods = { @@ -83,15 +46,20 @@ describe('DiffsStoreActions', () => { }); }); + beforeEach(() => { + mock = new MockAdapter(axios); + }); + afterEach(() => { ['requestAnimationFrame', 'requestIdleCallback'].forEach((method) => { global[method] = originalMethods[method]; }); createFlash.mockClear(); + mock.restore(); }); describe('setBaseConfig', () => { - it('should set given endpoint and project path', (done) => { + it('should set given endpoint and project path', () => { const endpoint = '/diffs/set/endpoint'; const endpointMetadata = '/diffs/set/endpoint/metadata'; const endpointBatch = '/diffs/set/endpoint/batch'; @@ -104,8 +72,8 @@ describe('DiffsStoreActions', () => { b: ['y', 'hash:a'], }; - testAction( - setBaseConfig, + return testAction( + diffActions.setBaseConfig, { endpoint, endpointBatch, @@ -153,23 +121,12 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); describe('fetchDiffFilesBatch', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should fetch batch diff files', (done) => { + it('should fetch batch diff files', () => { const endpointBatch = '/fetch/diffs_batch'; const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { total_pages: 7 } }; const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: { total_pages: 7 } }; @@ -199,8 +156,8 @@ describe('DiffsStoreActions', () => { ) .reply(200, res2); - testAction( - fetchDiffFilesBatch, + return testAction( + diffActions.fetchDiffFilesBatch, {}, { endpointBatch, diffViewType: 'inline', diffFiles: [] }, [ @@ -216,7 +173,6 @@ describe('DiffsStoreActions', () => { { type: types.SET_BATCH_LOADING_STATE, payload: 'error' }, ], [{ type: 'startRenderDiffsQueue' }, { type: 'startRenderDiffsQueue' }], - done, ); }); @@ -229,13 +185,14 @@ describe('DiffsStoreActions', () => { ({ viewStyle, otherView }) => { const endpointBatch = '/fetch/diffs_batch'; - fetchDiffFilesBatch({ - commit: () => {}, - state: { - endpointBatch: `${endpointBatch}?view=${otherView}`, - diffViewType: viewStyle, - }, - }) + diffActions + .fetchDiffFilesBatch({ + commit: () => {}, + state: { + endpointBatch: `${endpointBatch}?view=${otherView}`, + diffViewType: viewStyle, + }, + }) .then(() => { expect(mock.history.get[0].url).toContain(`view=${viewStyle}`); expect(mock.history.get[0].url).not.toContain(`view=${otherView}`); @@ -248,19 +205,16 @@ describe('DiffsStoreActions', () => { describe('fetchDiffFilesMeta', () => { const endpointMetadata = '/fetch/diffs_metadata.json?view=inline'; const noFilesData = { ...diffMetadata }; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); - delete noFilesData.diff_files; mock.onGet(endpointMetadata).reply(200, diffMetadata); }); - it('should fetch diff meta information', (done) => { - testAction( - fetchDiffFilesMeta, + it('should fetch diff meta information', () => { + return testAction( + diffActions.fetchDiffFilesMeta, {}, { endpointMetadata, diffViewType: 'inline' }, [ @@ -275,55 +229,41 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchCoverageFiles', () => { - let mock; const endpointCoverage = '/fetch'; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => mock.restore()); - - it('should commit SET_COVERAGE_DATA with received response', (done) => { + it('should commit SET_COVERAGE_DATA with received response', () => { const data = { files: { 'app.js': { 1: 0, 2: 1 } } }; mock.onGet(endpointCoverage).reply(200, { data }); - testAction( - fetchCoverageFiles, + return testAction( + diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [{ type: types.SET_COVERAGE_DATA, payload: { data } }], [], - done, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet(endpointCoverage).reply(400); - testAction(fetchCoverageFiles, {}, { endpointCoverage }, [], [], () => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ - message: expect.stringMatching('Something went wrong'), - }); - done(); + await testAction(diffActions.fetchCoverageFiles, {}, { endpointCoverage }, [], []); + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith({ + message: expect.stringMatching('Something went wrong'), }); }); }); describe('setHighlightedRow', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { - testAction(setHighlightedRow, 'ABC_123', {}, [ + return testAction(diffActions.setHighlightedRow, 'ABC_123', {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, { type: types.SET_CURRENT_DIFF_FILE, payload: 'ABC' }, ]); @@ -335,7 +275,7 @@ describe('DiffsStoreActions', () => { window.location.hash = ''; }); - it('should merge discussions into diffs', (done) => { + it('should merge discussions into diffs', () => { window.location.hash = 'ABC_123'; const state = { @@ -397,8 +337,8 @@ describe('DiffsStoreActions', () => { const discussions = [singleDiscussion]; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, discussions, state, [ @@ -425,26 +365,24 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('dispatches setCurrentDiffFileIdFromNote with note ID', (done) => { + it('dispatches setCurrentDiffFileIdFromNote with note ID', () => { window.location.hash = 'note_123'; - testAction( - assignDiscussionsToDiff, + return testAction( + diffActions.assignDiscussionsToDiff, [], { diffFiles: [] }, [], [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], - done, ); }); }); describe('removeDiscussionsFromDiff', () => { - it('should remove discussions from diffs', (done) => { + it('should remove discussions from diffs', () => { const state = { diffFiles: [ { @@ -480,8 +418,8 @@ describe('DiffsStoreActions', () => { line_code: 'ABC_1_1', }; - testAction( - removeDiscussionsFromDiff, + return testAction( + diffActions.removeDiscussionsFromDiff, singleDiscussion, state, [ @@ -495,7 +433,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -528,7 +465,7 @@ describe('DiffsStoreActions', () => { }); }; - startRenderDiffsQueue({ state, commit: pseudoCommit }); + diffActions.startRenderDiffsQueue({ state, commit: pseudoCommit }); expect(state.diffFiles[0].renderIt).toBe(true); expect(state.diffFiles[1].renderIt).toBe(true); @@ -536,69 +473,61 @@ describe('DiffsStoreActions', () => { }); describe('setInlineDiffViewType', () => { - it('should set diff view type to inline and also set the cookie properly', (done) => { - testAction( - setInlineDiffViewType, + it('should set diff view type to inline and also set the cookie properly', async () => { + await testAction( + diffActions.setInlineDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: INLINE_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get('diff_view')).toEqual(INLINE_DIFF_VIEW_TYPE); }); }); describe('setParallelDiffViewType', () => { - it('should set diff view type to parallel and also set the cookie properly', (done) => { - testAction( - setParallelDiffViewType, + it('should set diff view type to parallel and also set the cookie properly', async () => { + await testAction( + diffActions.setParallelDiffViewType, null, {}, [{ type: types.SET_DIFF_VIEW_TYPE, payload: PARALLEL_DIFF_VIEW_TYPE }], [], - () => { - expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); - done(); - }, ); + expect(Cookies.get(DIFF_VIEW_COOKIE_NAME)).toEqual(PARALLEL_DIFF_VIEW_TYPE); }); }); describe('showCommentForm', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - showCommentForm, + return testAction( + diffActions.showCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: true } }], [], - done, ); }); }); describe('cancelCommentForm', () => { - it('should call mutation to cancel comment form', (done) => { + it('should call mutation to cancel comment form', () => { const payload = { lineCode: 'lineCode', fileHash: 'hash' }; - testAction( - cancelCommentForm, + return testAction( + diffActions.cancelCommentForm, payload, {}, [{ type: types.TOGGLE_LINE_HAS_FORM, payload: { ...payload, hasForm: false } }], [], - done, ); }); }); describe('loadMoreLines', () => { - it('should call mutation to show comment form', (done) => { + it('should call mutation to show comment form', () => { const endpoint = '/diffs/load/more/lines'; const params = { since: 6, to: 26 }; const lineNumbers = { oldLineNumber: 3, newLineNumber: 5 }; @@ -606,12 +535,11 @@ describe('DiffsStoreActions', () => { const isExpandDown = false; const nextLineNumbers = {}; const options = { endpoint, params, lineNumbers, fileHash, isExpandDown, nextLineNumbers }; - const mock = new MockAdapter(axios); const contextLines = { contextLines: [{ lineCode: 6 }] }; mock.onGet(endpoint).reply(200, contextLines); - testAction( - loadMoreLines, + return testAction( + diffActions.loadMoreLines, options, {}, [ @@ -621,31 +549,23 @@ describe('DiffsStoreActions', () => { }, ], [], - () => { - mock.restore(); - done(); - }, ); }); }); describe('loadCollapsedDiff', () => { const state = { showWhitespace: true }; - it('should fetch data and call mutation with response and the give parameter', (done) => { + it('should fetch data and call mutation with response and the give parameter', () => { const file = { hash: 123, load_collapsed_diff_url: '/load/collapsed/diff/url' }; const data = { hash: 123, parallelDiffLines: [{ lineCode: 1 }] }; - const mock = new MockAdapter(axios); const commit = jest.fn(); mock.onGet(file.loadCollapsedDiffUrl).reply(200, data); - loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) + return diffActions + .loadCollapsedDiff({ commit, getters: { commitId: null }, state }, file) .then(() => { expect(commit).toHaveBeenCalledWith(types.ADD_COLLAPSED_DIFFS, { file, data }); - - mock.restore(); - done(); - }) - .catch(done.fail); + }); }); it('should fetch data without commit ID', () => { @@ -656,7 +576,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: null, w: '0' }, @@ -671,7 +591,7 @@ describe('DiffsStoreActions', () => { jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); - loadCollapsedDiff({ commit() {}, getters, state }, file); + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { params: { commit_id: '123', w: '0' }, @@ -689,7 +609,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'collapseDiscussion', @@ -707,7 +627,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -725,7 +645,7 @@ describe('DiffsStoreActions', () => { const dispatch = jest.fn(); - toggleFileDiscussions({ getters, dispatch }); + diffActions.toggleFileDiscussions({ getters, dispatch }); expect(dispatch).toHaveBeenCalledWith( 'expandDiscussion', @@ -743,7 +663,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -751,7 +671,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -759,14 +679,14 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'ABC_456', }, ); - scrollToLineIfNeededInline({}, lineMock); - scrollToLineIfNeededInline( + diffActions.scrollToLineIfNeededInline({}, lineMock); + diffActions.scrollToLineIfNeededInline( {}, { lineCode: 'XYZ_456', @@ -789,7 +709,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when there is not hash', () => { window.location.hash = ''; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -797,7 +717,7 @@ describe('DiffsStoreActions', () => { it('should not call handleLocationHash when the hash does not match any line', () => { window.location.hash = 'XYZ_456'; - scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel({}, lineMock); expect(commonUtils.handleLocationHash).not.toHaveBeenCalled(); }); @@ -805,7 +725,7 @@ describe('DiffsStoreActions', () => { it('should call handleLocationHash only when the hash matches a line', () => { window.location.hash = 'ABC_123'; - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -814,8 +734,8 @@ describe('DiffsStoreActions', () => { }, }, ); - scrollToLineIfNeededParallel({}, lineMock); - scrollToLineIfNeededParallel( + diffActions.scrollToLineIfNeededParallel({}, lineMock); + diffActions.scrollToLineIfNeededParallel( {}, { left: null, @@ -831,7 +751,7 @@ describe('DiffsStoreActions', () => { }); describe('saveDiffDiscussion', () => { - it('dispatches actions', (done) => { + it('dispatches actions', () => { const commitId = 'something'; const formData = { diffFile: { ...mockDiffFile }, @@ -856,33 +776,29 @@ describe('DiffsStoreActions', () => { } }); - saveDiffDiscussion({ state, dispatch }, { note, formData }) - .then(() => { - expect(dispatch).toHaveBeenCalledTimes(5); - expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { - root: true, - }); + return diffActions.saveDiffDiscussion({ state, dispatch }, { note, formData }).then(() => { + expect(dispatch).toHaveBeenCalledTimes(5); + expect(dispatch).toHaveBeenNthCalledWith(1, 'saveNote', expect.any(Object), { + root: true, + }); - const postData = dispatch.mock.calls[0][1]; - expect(postData.data.note.commit_id).toBe(commitId); + const postData = dispatch.mock.calls[0][1]; + expect(postData.data.note.commit_id).toBe(commitId); - expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); - expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); - }) - .then(done) - .catch(done.fail); + expect(dispatch).toHaveBeenNthCalledWith(2, 'updateDiscussion', 'test', { root: true }); + expect(dispatch).toHaveBeenNthCalledWith(3, 'assignDiscussionsToDiff', ['discussion']); + }); }); }); describe('toggleTreeOpen', () => { - it('commits TOGGLE_FOLDER_OPEN', (done) => { - testAction( - toggleTreeOpen, + it('commits TOGGLE_FOLDER_OPEN', () => { + return testAction( + diffActions.toggleTreeOpen, 'path', {}, [{ type: types.TOGGLE_FOLDER_OPEN, payload: 'path' }], [], - done, ); }); }); @@ -904,7 +820,7 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(document.location.hash).toBe('#test'); }); @@ -918,28 +834,27 @@ describe('DiffsStoreActions', () => { }, }; - scrollToFile({ state, commit, getters }, { path: 'path' }); + diffActions.scrollToFile({ state, commit, getters }, { path: 'path' }); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, 'test'); }); }); describe('setShowTreeList', () => { - it('commits toggle', (done) => { - testAction( - setShowTreeList, + it('commits toggle', () => { + return testAction( + diffActions.setShowTreeList, { showTreeList: true }, {}, [{ type: types.SET_SHOW_TREE_LIST, payload: true }], [], - done, ); }); it('updates localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true); }); @@ -947,7 +862,7 @@ describe('DiffsStoreActions', () => { it('does not update localStorage', () => { jest.spyOn(localStorage, 'setItem').mockImplementation(() => {}); - setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); + diffActions.setShowTreeList({ commit() {} }, { showTreeList: true, saving: false }); expect(localStorage.setItem).not.toHaveBeenCalled(); }); @@ -994,7 +909,7 @@ describe('DiffsStoreActions', () => { it('renders and expands file for the given discussion id', () => { const localState = state({ collapsed: true, renderIt: false }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]); expect($emit).toHaveBeenCalledTimes(1); @@ -1004,7 +919,7 @@ describe('DiffsStoreActions', () => { it('jumps to discussion on already rendered and expanded file', () => { const localState = state({ collapsed: false, renderIt: true }); - renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); + diffActions.renderFileForDiscussionId({ rootState, state: localState, commit }, '123'); expect(commit).not.toHaveBeenCalled(); expect($emit).toHaveBeenCalledTimes(1); @@ -1013,19 +928,18 @@ describe('DiffsStoreActions', () => { }); describe('setRenderTreeList', () => { - it('commits SET_RENDER_TREE_LIST', (done) => { - testAction( - setRenderTreeList, + it('commits SET_RENDER_TREE_LIST', () => { + return testAction( + diffActions.setRenderTreeList, { renderTreeList: true }, {}, [{ type: types.SET_RENDER_TREE_LIST, payload: true }], [], - done, ); }); it('sets localStorage', () => { - setRenderTreeList({ commit() {} }, { renderTreeList: true }); + diffActions.setRenderTreeList({ commit() {} }, { renderTreeList: true }); expect(localStorage.setItem).toHaveBeenCalledWith('mr_diff_tree_list', true); }); @@ -1034,11 +948,9 @@ describe('DiffsStoreActions', () => { describe('setShowWhitespace', () => { const endpointUpdateUser = 'user/prefs'; let putSpy; - let mock; let gon; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); gon = window.gon; @@ -1047,25 +959,23 @@ describe('DiffsStoreActions', () => { }); afterEach(() => { - mock.restore(); window.gon = gon; }); - it('commits SET_SHOW_WHITESPACE', (done) => { - testAction( - setShowWhitespace, + it('commits SET_SHOW_WHITESPACE', () => { + return testAction( + diffActions.setShowWhitespace, { showWhitespace: true, updateDatabase: false }, {}, [{ type: types.SET_SHOW_WHITESPACE, payload: true }], [], - done, ); }); it('saves to the database when the user is logged in', async () => { window.gon = { current_user_id: 12345 }; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1076,7 +986,7 @@ describe('DiffsStoreActions', () => { it('does not try to save to the API if the user is not logged in', async () => { window.gon = {}; - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: { endpointUpdateUser }, commit() {} }, { showWhitespace: true, updateDatabase: true }, ); @@ -1085,7 +995,7 @@ describe('DiffsStoreActions', () => { }); it('emits eventHub event', async () => { - await setShowWhitespace( + await diffActions.setShowWhitespace( { state: {}, commit() {} }, { showWhitespace: true, updateDatabase: false }, ); @@ -1095,53 +1005,47 @@ describe('DiffsStoreActions', () => { }); describe('setRenderIt', () => { - it('commits RENDER_FILE', (done) => { - testAction(setRenderIt, 'file', {}, [{ type: types.RENDER_FILE, payload: 'file' }], [], done); + it('commits RENDER_FILE', () => { + return testAction( + diffActions.setRenderIt, + 'file', + {}, + [{ type: types.RENDER_FILE, payload: 'file' }], + [], + ); }); }); describe('receiveFullDiffError', () => { - it('updates state with the file that did not load', (done) => { - testAction( - receiveFullDiffError, + it('updates state with the file that did not load', () => { + return testAction( + diffActions.receiveFullDiffError, 'file', {}, [{ type: types.RECEIVE_FULL_DIFF_ERROR, payload: 'file' }], [], - done, ); }); }); describe('fetchFullDiff', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - describe('success', () => { beforeEach(() => { mock.onGet(`${TEST_HOST}/context`).replyOnce(200, ['test']); }); - it('commits the success and dispatches an action to expand the new lines', (done) => { + it('commits the success and dispatches an action to expand the new lines', () => { const file = { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test', }; - testAction( - fetchFullDiff, + return testAction( + diffActions.fetchFullDiff, file, null, [{ type: types.RECEIVE_FULL_DIFF_SUCCESS, payload: { filePath: 'test' } }], [{ type: 'setExpandedDiffLines', payload: { file, data: ['test'] } }], - done, ); }); }); @@ -1151,14 +1055,13 @@ describe('DiffsStoreActions', () => { mock.onGet(`${TEST_HOST}/context`).replyOnce(500); }); - it('dispatches receiveFullDiffError', (done) => { - testAction( - fetchFullDiff, + it('dispatches receiveFullDiffError', () => { + return testAction( + diffActions.fetchFullDiff, { context_lines_path: `${TEST_HOST}/context`, file_path: 'test', file_hash: 'test' }, null, [], [{ type: 'receiveFullDiffError', payload: 'test' }], - done, ); }); }); @@ -1173,14 +1076,13 @@ describe('DiffsStoreActions', () => { }; }); - it('dispatches fetchFullDiff when file is not expanded', (done) => { - testAction( - toggleFullDiff, + it('dispatches fetchFullDiff when file is not expanded', () => { + return testAction( + diffActions.toggleFullDiff, 'test', state, [{ type: types.REQUEST_FULL_DIFF, payload: 'test' }], [{ type: 'fetchFullDiff', payload: state.diffFiles[0] }], - done, ); }); }); @@ -1202,16 +1104,13 @@ describe('DiffsStoreActions', () => { }; const testData = [{ rich_text: 'test' }, { rich_text: 'file2' }]; let renamedFile; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); jest.spyOn(utils, 'prepareLineForRenamedFile').mockImplementation(() => preparedLine); }); afterEach(() => { renamedFile = null; - mock.restore(); }); describe('success', () => { @@ -1228,7 +1127,7 @@ describe('DiffsStoreActions', () => { 'performs the correct mutations and starts a render queue for view type $diffViewType', ({ diffViewType }) => { return testAction( - switchToFullDiffFromRenamedFile, + diffActions.switchToFullDiffFromRenamedFile, { diffFile: renamedFile }, { diffViewType }, [ @@ -1249,9 +1148,9 @@ describe('DiffsStoreActions', () => { }); describe('setFileUserCollapsed', () => { - it('commits SET_FILE_COLLAPSED', (done) => { - testAction( - setFileCollapsedByUser, + it('commits SET_FILE_COLLAPSED', () => { + return testAction( + diffActions.setFileCollapsedByUser, { filePath: 'test', collapsed: true }, null, [ @@ -1261,7 +1160,6 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); }); @@ -1273,11 +1171,11 @@ describe('DiffsStoreActions', () => { }); }); - it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits SET_CURRENT_VIEW_DIFF_FILE_LINES when lines less than MAX_RENDERING_DIFF_LINES', () => { utils.convertExpandLines.mockImplementation(() => ['test']); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1287,16 +1185,15 @@ describe('DiffsStoreActions', () => { }, ], [], - done, ); }); - it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', (done) => { + it('commits ADD_CURRENT_VIEW_DIFF_FILE_LINES when lines more than MAX_RENDERING_DIFF_LINES', () => { const lines = new Array(501).fill().map((_, i) => `line-${i}`); utils.convertExpandLines.mockReturnValue(lines); - testAction( - setExpandedDiffLines, + return testAction( + diffActions.setExpandedDiffLines, { file: { file_path: 'path' }, data: [] }, { diffViewType: 'inline' }, [ @@ -1312,41 +1209,34 @@ describe('DiffsStoreActions', () => { { type: 'TOGGLE_DIFF_FILE_RENDERING_MORE', payload: 'path' }, ], [], - done, ); }); }); describe('setSuggestPopoverDismissed', () => { - it('commits SET_SHOW_SUGGEST_POPOVER', (done) => { + it('commits SET_SHOW_SUGGEST_POPOVER', async () => { const state = { dismissEndpoint: `${TEST_HOST}/-/user_callouts` }; - const mock = new MockAdapter(axios); mock.onPost(state.dismissEndpoint).reply(200, {}); jest.spyOn(axios, 'post'); - testAction( - setSuggestPopoverDismissed, + await testAction( + diffActions.setSuggestPopoverDismissed, null, state, [{ type: types.SET_SHOW_SUGGEST_POPOVER }], [], - () => { - expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { - feature_name: 'suggest_popover_dismissed', - }); - - mock.restore(); - done(); - }, ); + expect(axios.post).toHaveBeenCalledWith(state.dismissEndpoint, { + feature_name: 'suggest_popover_dismissed', + }); }); }); describe('changeCurrentCommit', () => { it('commits the new commit information and re-requests the diff metadata for the commit', () => { return testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId: 'NEW' }, { commit: { @@ -1384,7 +1274,7 @@ describe('DiffsStoreActions', () => { ({ commitId, commit, msg }) => { const err = new Error(msg); const actionReturn = testAction( - changeCurrentCommit, + diffActions.changeCurrentCommit, { commitId }, { endpoint: 'URL/OLD', @@ -1410,7 +1300,7 @@ describe('DiffsStoreActions', () => { 'for the direction "$direction", dispatches the action to move to the SHA "$expected"', ({ direction, expected, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit }, [], @@ -1431,7 +1321,7 @@ describe('DiffsStoreActions', () => { 'given `{ "isloading": $diffsAreLoading, "commit": $currentCommit }` in state, no actions are dispatched', ({ direction, diffsAreLoading, currentCommit }) => { return testAction( - moveToNeighboringCommit, + diffActions.moveToNeighboringCommit, { direction }, { commit: currentCommit, isLoading: diffsAreLoading }, [], @@ -1450,7 +1340,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).toHaveBeenCalledWith(types.SET_CURRENT_DIFF_FILE, '123'); }); @@ -1463,7 +1353,7 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); @@ -1476,21 +1366,20 @@ describe('DiffsStoreActions', () => { notesById: { 1: { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + diffActions.setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); }); describe('navigateToDiffFileIndex', () => { - it('commits SET_CURRENT_DIFF_FILE', (done) => { - testAction( - navigateToDiffFileIndex, + it('commits SET_CURRENT_DIFF_FILE', () => { + return testAction( + diffActions.navigateToDiffFileIndex, 0, { diffFiles: [{ file_hash: '123' }] }, [{ type: types.SET_CURRENT_DIFF_FILE, payload: '123' }], [], - done, ); }); }); @@ -1498,19 +1387,13 @@ describe('DiffsStoreActions', () => { describe('setFileByFile', () => { const updateUserEndpoint = 'user/prefs'; let putSpy; - let mock; beforeEach(() => { - mock = new MockAdapter(axios); putSpy = jest.spyOn(axios, 'put'); mock.onPut(updateUserEndpoint).reply(200, {}); }); - afterEach(() => { - mock.restore(); - }); - it.each` value ${true} @@ -1519,7 +1402,7 @@ describe('DiffsStoreActions', () => { 'commits SET_FILE_BY_FILE and persists the File-by-File user preference with the new value $value', async ({ value }) => { await testAction( - setFileByFile, + diffActions.setFileByFile, { fileByFile: value }, { viewDiffsFileByFile: null, @@ -1551,7 +1434,7 @@ describe('DiffsStoreActions', () => { const commitSpy = jest.fn(); const getterSpy = jest.fn().mockReturnValue([]); - reviewFile( + diffActions.reviewFile( { commit: commitSpy, getters: { diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 55c0141552d..03bcaab0d2b 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -13,7 +13,7 @@ import { } from '~/diffs/constants'; import * as utils from '~/diffs/store/utils'; import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; -import { noteableDataMock } from '../../notes/mock_data'; +import { noteableDataMock } from 'jest/notes/mock_data'; import diffFileMockData from '../mock_data/diff_file'; import { diffMetadata } from '../mock_data/diff_metadata'; diff --git a/spec/frontend/editor/components/helpers.js b/spec/frontend/editor/components/helpers.js new file mode 100644 index 00000000000..3e6cd2a236d --- /dev/null +++ b/spec/frontend/editor/components/helpers.js @@ -0,0 +1,12 @@ +import { EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; + +export const buildButton = (id = 'foo-bar-btn', options = {}) => { + return { + __typename: 'Item', + id, + label: options.label || 'Foo Bar Button', + icon: options.icon || 'foo-bar', + selected: options.selected || false, + group: options.group || EDITOR_TOOLBAR_RIGHT_GROUP, + }; +}; diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js new file mode 100644 index 00000000000..5135091af4a --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -0,0 +1,146 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import getToolbarItemQuery from '~/editor/graphql/get_item.query.graphql'; +import updateToolbarItemMutation from '~/editor/graphql/update_item.mutation.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar button', () => { + let wrapper; + let mockApollo; + const defaultBtn = buildButton(); + + const findButton = () => wrapper.findComponent(GlButton); + + const createComponentWithApollo = ({ propsData } = {}) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id: defaultBtn.id }, + data: { + item: { + ...defaultBtn, + }, + }, + }); + + wrapper = shallowMount(SourceEditorToolbarButton, { + propsData, + apolloProvider: mockApollo, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('default', () => { + const defaultProps = { + category: 'primary', + variant: 'default', + }; + const customProps = { + category: 'secondary', + variant: 'info', + }; + it('renders a default button without props', async () => { + createComponentWithApollo(); + const btn = findButton(); + expect(btn.exists()).toBe(true); + expect(btn.props()).toMatchObject(defaultProps); + }); + + it('renders a button based on the props passed', async () => { + createComponentWithApollo({ + propsData: { + button: customProps, + }, + }); + const btn = findButton(); + expect(btn.props()).toMatchObject(customProps); + }); + }); + + describe('button updates', () => { + it('it properly updates button on Apollo cache update', async () => { + const { id } = defaultBtn; + + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + + expect(findButton().props('selected')).toBe(false); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemQuery, + variables: { id }, + data: { + item: { + ...defaultBtn, + selected: true, + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButton().props('selected')).toBe(true); + }); + }); + + describe('click handler', () => { + it('fires the click handler on the button when available', () => { + const spy = jest.fn(); + createComponentWithApollo({ + propsData: { + button: { + onClick: spy, + }, + }, + }); + expect(spy).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(spy).toHaveBeenCalled(); + }); + it('emits the "click" event', () => { + createComponentWithApollo(); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + }); + it('triggers the mutation exposing the changed "selected" prop', () => { + const { id } = defaultBtn; + createComponentWithApollo({ + propsData: { + button: { + id, + }, + }, + }); + jest.spyOn(wrapper.vm.$apollo, 'mutate'); + expect(wrapper.vm.$apollo.mutate).not.toHaveBeenCalled(); + findButton().vm.$emit('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateToolbarItemMutation, + variables: { + id, + propsToUpdate: { + selected: true, + }, + }, + }); + }); + }); +}); diff --git a/spec/frontend/editor/components/source_editor_toolbar_spec.js b/spec/frontend/editor/components/source_editor_toolbar_spec.js new file mode 100644 index 00000000000..6e99eadbd97 --- /dev/null +++ b/spec/frontend/editor/components/source_editor_toolbar_spec.js @@ -0,0 +1,116 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlButtonGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SourceEditorToolbar from '~/editor/components/source_editor_toolbar.vue'; +import SourceEditorToolbarButton from '~/editor/components/source_editor_toolbar_button.vue'; +import { EDITOR_TOOLBAR_LEFT_GROUP, EDITOR_TOOLBAR_RIGHT_GROUP } from '~/editor/constants'; +import getToolbarItemsQuery from '~/editor/graphql/get_items.query.graphql'; +import { buildButton } from './helpers'; + +Vue.use(VueApollo); + +describe('Source Editor Toolbar', () => { + let wrapper; + let mockApollo; + + const findButtons = () => wrapper.findAllComponents(SourceEditorToolbarButton); + + const createApolloMockWithCache = (items = []) => { + mockApollo = createMockApollo(); + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: items, + }, + }, + }); + }; + + const createComponentWithApollo = (items = []) => { + createApolloMockWithCache(items); + wrapper = shallowMount(SourceEditorToolbar, { + apolloProvider: mockApollo, + stubs: { + GlButtonGroup, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockApollo = null; + }); + + describe('groups', () => { + it.each` + group | expectedGroup + ${EDITOR_TOOLBAR_LEFT_GROUP} | ${EDITOR_TOOLBAR_LEFT_GROUP} + ${EDITOR_TOOLBAR_RIGHT_GROUP} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${undefined} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + ${'non-existing'} | ${EDITOR_TOOLBAR_RIGHT_GROUP} + `('puts item with group="$group" into $expectedGroup group', ({ group, expectedGroup }) => { + const item = buildButton('first', { + group, + }); + createComponentWithApollo([item]); + expect(findButtons()).toHaveLength(1); + [EDITOR_TOOLBAR_RIGHT_GROUP, EDITOR_TOOLBAR_LEFT_GROUP].forEach((g) => { + if (g === expectedGroup) { + expect(wrapper.vm.getGroupItems(g)).toEqual([expect.objectContaining({ id: 'first' })]); + } else { + expect(wrapper.vm.getGroupItems(g)).toHaveLength(0); + } + }); + }); + }); + + describe('buttons update', () => { + it('it properly updates buttons on Apollo cache update', async () => { + const item = buildButton('first', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo(); + + expect(findButtons()).toHaveLength(0); + + mockApollo.clients.defaultClient.cache.writeQuery({ + query: getToolbarItemsQuery, + data: { + items: { + nodes: [item], + }, + }, + }); + + jest.runOnlyPendingTimers(); + await nextTick(); + + expect(findButtons()).toHaveLength(1); + }); + }); + + describe('click handler', () => { + it('emits the "click" event when a button is clicked', () => { + const item1 = buildButton('first', { + group: EDITOR_TOOLBAR_LEFT_GROUP, + }); + const item2 = buildButton('second', { + group: EDITOR_TOOLBAR_RIGHT_GROUP, + }); + createComponentWithApollo([item1, item2]); + jest.spyOn(wrapper.vm, '$emit'); + expect(wrapper.vm.$emit).not.toHaveBeenCalled(); + + findButtons().at(0).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item1); + + findButtons().at(1).vm.$emit('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', item2); + + expect(wrapper.vm.$emit.mock.calls).toHaveLength(2); + }); + }); +}); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js new file mode 100644 index 00000000000..628c34a27c1 --- /dev/null +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -0,0 +1,90 @@ +import Ajv from 'ajv'; +import AjvFormats from 'ajv-formats'; +import CiSchema from '~/editor/schema/ci.json'; + +// JSON POSITIVE TESTS +import AllowFailureJson from './json_tests/positive_tests/allow_failure.json'; +import EnvironmentJson from './json_tests/positive_tests/environment.json'; +import GitlabCiDependenciesJson from './json_tests/positive_tests/gitlab-ci-dependencies.json'; +import GitlabCiJson from './json_tests/positive_tests/gitlab-ci.json'; +import InheritJson from './json_tests/positive_tests/inherit.json'; +import MultipleCachesJson from './json_tests/positive_tests/multiple-caches.json'; +import RetryJson from './json_tests/positive_tests/retry.json'; +import TerraformReportJson from './json_tests/positive_tests/terraform_report.json'; +import VariablesMixStringAndUserInputJson from './json_tests/positive_tests/variables_mix_string_and_user_input.json'; +import VariablesJson from './json_tests/positive_tests/variables.json'; + +// JSON NEGATIVE TESTS +import DefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/default_no_additional_properties.json'; +import InheritDefaultNoAdditionalPropertiesJson from './json_tests/negative_tests/inherit_default_no_additional_properties.json'; +import JobVariablesMustNotContainObjectsJson from './json_tests/negative_tests/job_variables_must_not_contain_objects.json'; +import ReleaseAssetsLinksEmptyJson from './json_tests/negative_tests/release_assets_links_empty.json'; +import ReleaseAssetsLinksInvalidLinkTypeJson from './json_tests/negative_tests/release_assets_links_invalid_link_type.json'; +import ReleaseAssetsLinksMissingJson from './json_tests/negative_tests/release_assets_links_missing.json'; +import RetryUnknownWhenJson from './json_tests/negative_tests/retry_unknown_when.json'; + +// YAML POSITIVE TEST +import CacheYaml from './yaml_tests/positive_tests/cache.yml'; +import FilterYaml from './yaml_tests/positive_tests/filter.yml'; +import IncludeYaml from './yaml_tests/positive_tests/include.yml'; +import RulesYaml from './yaml_tests/positive_tests/rules.yml'; + +// YAML NEGATIVE TEST +import CacheNegativeYaml from './yaml_tests/negative_tests/cache.yml'; +import IncludeNegativeYaml from './yaml_tests/negative_tests/include.yml'; + +const ajv = new Ajv({ + strictTypes: false, + strictTuples: false, + allowMatchingProperties: true, +}); + +AjvFormats(ajv); +const schema = ajv.compile(CiSchema); + +describe('positive tests', () => { + it.each( + Object.entries({ + // JSON + AllowFailureJson, + EnvironmentJson, + GitlabCiDependenciesJson, + GitlabCiJson, + InheritJson, + MultipleCachesJson, + RetryJson, + TerraformReportJson, + VariablesMixStringAndUserInputJson, + VariablesJson, + + // YAML + CacheYaml, + FilterYaml, + IncludeYaml, + RulesYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).toValidateJsonSchema(schema); + }); +}); + +describe('negative tests', () => { + it.each( + Object.entries({ + // JSON + DefaultNoAdditionalPropertiesJson, + JobVariablesMustNotContainObjectsJson, + InheritDefaultNoAdditionalPropertiesJson, + ReleaseAssetsLinksEmptyJson, + ReleaseAssetsLinksInvalidLinkTypeJson, + ReleaseAssetsLinksMissingJson, + RetryUnknownWhenJson, + + // YAML + CacheNegativeYaml, + IncludeNegativeYaml, + }), + )('schema validates %s', (_, input) => { + expect(input).not.toValidateJsonSchema(schema); + }); +}); diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json new file mode 100644 index 00000000000..955c19ef1ab --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/default_no_additional_properties.json @@ -0,0 +1,12 @@ +{ + "default": { + "secrets": { + "DATABASE_PASSWORD": { + "vault": "production/db/password" + } + }, + "environment": { + "name": "test" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json new file mode 100644 index 00000000000..7411e4c2434 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/inherit_default_no_additional_properties.json @@ -0,0 +1,8 @@ +{ + "karma": { + "inherit": { + "default": ["secrets"] + }, + "script": "karma" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json new file mode 100644 index 00000000000..bfdbf26ee70 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/job_variables_must_not_contain_objects.json @@ -0,0 +1,12 @@ +{ + "gitlab-ci-variables-object": { + "stage": "test", + "script": ["true"], + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json new file mode 100644 index 00000000000..84a1aa14698 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_empty.json @@ -0,0 +1,13 @@ +{ + "gitlab-ci-release-assets-links-empty": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json new file mode 100644 index 00000000000..048911aefa3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_invalid_link_type.json @@ -0,0 +1,24 @@ +{ + "gitlab-ci-release-assets-links-invalid-link-type": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "invalid" + } + ] + } + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json new file mode 100644 index 00000000000..6f0b5a3bff8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/release_assets_links_missing.json @@ -0,0 +1,11 @@ +{ + "gitlab-ci-release-assets-links-missing": { + "script": "dostuff", + "stage": "deploy", + "release": { + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "assets": {} + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json new file mode 100644 index 00000000000..433504f52c6 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/negative_tests/retry_unknown_when.json @@ -0,0 +1,9 @@ +{ + "gitlab-ci-retry-object-unknown-when": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "gitlab-ci-retry-object-unknown-when" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json new file mode 100644 index 00000000000..44d42116c1a --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/allow_failure.json @@ -0,0 +1,19 @@ +{ + "job1": { + "stage": "test", + "script": ["execute_script_that_will_fail"], + "allow_failure": true + }, + "job2": { + "script": ["exit 1"], + "allow_failure": { + "exit_codes": 137 + } + }, + "job3": { + "script": ["exit 137"], + "allow_failure": { + "exit_codes": [137, 255] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json new file mode 100644 index 00000000000..0c6f7935063 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/environment.json @@ -0,0 +1,75 @@ +{ + "deploy to production 1": { + "stage": "deploy", + "script": "git push production HEAD: master", + "environment": "production" + }, + "deploy to production 2": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production" + } + }, + "deploy to production 3": { + "stage": "deploy", + "script": "git push production HEAD:master", + "environment": { + "name": "production", + "url": "https://prod.example.com" + } + }, + "review_app 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com", + "on_stop": "stop_review_app" + } + }, + "stop_review_app": { + "stage": "deploy", + "variables": { + "GIT_STRATEGY": "none" + }, + "script": "make delete-app", + "when": "manual", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "action": "stop" + } + }, + "review_app 2": { + "script": "deploy-review-app", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "auto_stop_in": "1 day" + } + }, + "deploy 1": { + "stage": "deploy", + "script": "make deploy-app", + "environment": { + "name": "production", + "kubernetes": { + "namespace": "production" + } + } + }, + "deploy 2": { + "script": "echo", + "environment": { + "name": "customer-portal", + "deployment_tier": "production" + } + }, + "deploy as review app": { + "stage": "deploy", + "script": "make deploy", + "environment": { + "name": "review/$CI_COMMIT_REF_NAME", + "url": "https://$CI_ENVIRONMENT_SLUG.example.com/" + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json new file mode 100644 index 00000000000..5ffa7fa799e --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci-dependencies.json @@ -0,0 +1,68 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"] + }, + "stages": ["install", "build", "test", "deploy"], + "image": "foo:latest", + "install task1": { + "image": "node:latest", + "stage": "install", + "script": "npm install", + "artifacts": { + "paths": ["node_modules/"] + } + }, + "build dev": { + "image": "node:latest", + "stage": "build", + "needs": [ + { + "job": "install task1" + } + ], + "script": "npm run build:dev" + }, + "build prod": { + "image": "node:latest", + "stage": "build", + "needs": ["install task1"], + "script": "npm run build:prod" + }, + "test": { + "image": "node:latest", + "stage": "build", + "needs": [ + "install task1", + { + "job": "build dev", + "artifacts": true + } + ], + "script": "npm run test" + }, + "deploy it": { + "image": "node:latest", + "stage": "deploy", + "needs": [ + { + "job": "build dev", + "artifacts": false + }, + { + "job": "build prod", + "artifacts": true + } + ], + "script": "npm run test" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json new file mode 100644 index 00000000000..89420bbc35f --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/gitlab-ci.json @@ -0,0 +1,350 @@ +{ + ".build_config": { + "before_script": ["echo test"] + }, + ".build_script": "echo build script", + ".example_variables": { + "foo": "hello", + "bar": 42 + }, + ".example_services": [ + "docker:dind", + { + "name": "sql:latest", + "command": ["/usr/bin/super-sql", "run"] + } + ], + "default": { + "image": "ruby:2.5", + "services": ["docker:dind"], + "cache": { + "paths": ["vendor/"] + }, + "before_script": ["bundle install --path vendor/"], + "after_script": ["rm -rf tmp/"], + "tags": ["ruby", "postgres"], + "artifacts": { + "name": "%CI_COMMIT_REF_NAME%", + "expose_as": "artifact 1", + "paths": ["path/to/file.txt", "target/*.war"], + "when": "on_failure" + }, + "retry": 2, + "timeout": "3 hours 30 minutes", + "interruptible": true + }, + "stages": ["build", "test", "deploy", "random"], + "image": "foo:latest", + "services": ["sql:latest"], + "before_script": ["echo test", "echo test2"], + "after_script": [], + "cache": { + "key": "asd", + "paths": ["dist/", ".foo"], + "untracked": false, + "policy": "pull" + }, + "variables": { + "STAGE": "yep", + "PROD": "nope" + }, + "include": [ + "https://gitlab.com/awesome-project/raw/master/.before-script-template.yml", + "/templates/.after-script-template.yml", + { "template": "Auto-DevOps.gitlab-ci.yml" }, + { + "project": "my-group/my-project", + "ref": "master", + "file": "/templates/.gitlab-ci-template.yml" + }, + { + "project": "my-group/my-project", + "ref": "master", + "file": ["/templates/.gitlab-ci-template.yml", "/templates/another-template-to-include.yml"] + } + ], + "build": { + "image": { + "name": "node:latest" + }, + "services": [], + "stage": "build", + "script": "npm run build", + "before_script": ["npm install"], + "rules": [ + { + "if": "moo", + "changes": ["Moofile"], + "exists": ["main.cow"], + "when": "delayed", + "start_in": "3 hours" + } + ], + "retry": { + "max": 1, + "when": "stuck_or_timeout_failure" + }, + "cache": { + "key": "$CI_COMMIT_REF_NAME", + "paths": ["node_modules/"], + "policy": "pull-push" + }, + "artifacts": { + "paths": ["dist/"], + "expose_as": "link_name_in_merge_request", + "name": "bundles", + "when": "on_success", + "expire_in": "1 week", + "reports": { + "junit": "result.xml", + "cobertura": "cobertura-coverage.xml", + "codequality": "codequality.json", + "sast": "sast.json", + "dependency_scanning": "scan.json", + "container_scanning": "scan2.json", + "dast": "dast.json", + "license_management": "license.json", + "performance": "performance.json", + "metrics": "metrics.txt" + } + }, + "variables": { + "FOO_BAR": "..." + }, + "only": { + "kubernetes": "active", + "variables": ["$FOO_BAR == '...'"], + "changes": ["/path/to/file", "/another/file"] + }, + "except": ["master", "tags"], + "tags": ["docker"], + "allow_failure": true, + "when": "manual" + }, + "error-report": { + "when": "on_failure", + "script": "report error", + "stage": "test" + }, + "test": { + "image": { + "name": "node:latest", + "entrypoint": [""] + }, + "stage": "test", + "script": "npm test", + "parallel": 5, + "retry": { + "max": 2, + "when": [ + "runner_system_failure", + "stuck_or_timeout_failure", + "script_failure", + "unknown_failure", + "always" + ] + }, + "artifacts": { + "reports": { + "junit": ["result.xml"], + "cobertura": ["cobertura-coverage.xml"], + "codequality": ["codequality.json"], + "sast": ["sast.json"], + "dependency_scanning": ["scan.json"], + "container_scanning": ["scan2.json"], + "dast": ["dast.json"], + "license_management": ["license.json"], + "performance": ["performance.json"], + "metrics": ["metrics.txt"] + } + }, + "coverage": "/Cycles: \\d+\\.\\d+$/", + "dependencies": [] + }, + "docker": { + "script": "docker build -t foo:latest", + "when": "delayed", + "start_in": "10 min", + "timeout": "1h", + "retry": 1, + "only": { + "changes": ["Dockerfile", "docker/scripts/*"] + } + }, + "deploy": { + "services": [ + { + "name": "sql:latest", + "entrypoint": [""], + "command": ["/usr/bin/super-sql", "run"], + "alias": "super-sql" + }, + "sql:latest", + { + "name": "sql:latest", + "alias": "default-sql" + } + ], + "script": "dostuff", + "stage": "deploy", + "environment": { + "name": "prod", + "url": "http://example.com", + "on_stop": "stop-deploy" + }, + "only": ["master"], + "release": { + "name": "Release $CI_COMMIT_TAG", + "description": "Created using the release-cli $EXTRA_DESCRIPTION", + "tag_name": "$CI_COMMIT_TAG", + "ref": "$CI_COMMIT_TAG", + "milestones": ["m1", "m2", "m3"], + "released_at": "2020-07-15T08:00:00Z", + "assets": { + "links": [ + { + "name": "asset1", + "url": "https://example.com/assets/1" + }, + { + "name": "asset2", + "url": "https://example.com/assets/2", + "filepath": "/pretty/url/1", + "link_type": "other" + }, + { + "name": "asset3", + "url": "https://example.com/assets/3", + "link_type": "runbook" + }, + { + "name": "asset4", + "url": "https://example.com/assets/4", + "link_type": "package" + }, + { + "name": "asset5", + "url": "https://example.com/assets/5", + "link_type": "image" + } + ] + } + } + }, + ".performance-tmpl": { + "after_script": ["echo after"], + "before_script": ["echo before"], + "variables": { + "SCRIPT_NOT_REQUIRED": "true" + } + }, + "performance-a": { + "extends": ".performance-tmpl", + "script": "echo test" + }, + "performance-b": { + "extends": ".performance-tmpl" + }, + "workflow": { + "rules": [ + { + "if": "$CI_COMMIT_REF_NAME =~ /-wip$/", + "when": "never" + }, + { + "if": "$CI_COMMIT_TAG", + "when": "never" + }, + { + "when": "always" + } + ] + }, + "job": { + "script": "echo Hello, Rules!", + "rules": [ + { + "if": "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME == \"master\"", + "when": "manual", + "allow_failure": true + } + ] + }, + "microservice_a": { + "trigger": { + "include": "path/to/microservice_a.yml" + } + }, + "microservice_b": { + "trigger": { + "include": [{ "local": "path/to/microservice_b.yml" }, { "template": "SAST.gitlab-cy.yml" }], + "strategy": "depend" + } + }, + "child-pipeline": { + "stage": "test", + "trigger": { + "include": [ + { + "artifact": "generated-config.yml", + "job": "generate-config" + } + ] + } + }, + "child-pipeline-simple": { + "stage": "test", + "trigger": { + "include": "other/file.yml" + } + }, + "complex": { + "stage": "deploy", + "trigger": { + "project": "my/deployment", + "branch": "stable" + } + }, + "parallel-integer": { + "stage": "test", + "script": ["echo ${CI_NODE_INDEX} ${CI_NODE_TOTAL}"], + "parallel": 5 + }, + "parallel-matrix-simple": { + "stage": "test", + "script": ["echo ${MY_VARIABLE}"], + "parallel": { + "matrix": [ + { + "MY_VARIABLE": 0 + }, + { + "MY_VARIABLE": "sample" + }, + { + "MY_VARIABLE": ["element0", 1, "element2"] + } + ] + } + }, + "parallel-matrix-gitlab-docs": { + "stage": "deploy", + "script": ["bin/deploy"], + "parallel": { + "matrix": [ + { + "PROVIDER": "aws", + "STACK": ["app1", "app2"] + }, + { + "PROVIDER": "ovh", + "STACK": ["monitoring", "backup", "app"] + }, + { + "PROVIDER": ["gcp", "vultr"], + "STACK": ["data", "processing"] + } + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json new file mode 100644 index 00000000000..3f72afa6ceb --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/inherit.json @@ -0,0 +1,54 @@ +{ + "default": { + "image": "ruby:2.4", + "before_script": ["echo Hello World"] + }, + "variables": { + "DOMAIN": "example.com", + "WEBHOOK_URL": "https://my-webhook.example.com" + }, + "rubocop": { + "inherit": { + "default": false, + "variables": false + }, + "script": "bundle exec rubocop" + }, + "rspec": { + "inherit": { + "default": ["image"], + "variables": ["WEBHOOK_URL"] + }, + "script": "bundle exec rspec" + }, + "capybara": { + "inherit": { + "variables": false + }, + "script": "bundle exec capybara" + }, + "karma": { + "inherit": { + "default": true, + "variables": ["DOMAIN"] + }, + "script": "karma" + }, + "inherit literally all": { + "inherit": { + "default": [ + "after_script", + "artifacts", + "before_script", + "cache", + "image", + "interruptible", + "retry", + "services", + "tags", + "timeout" + ] + }, + "script": "true" + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json new file mode 100644 index 00000000000..360938e5ce7 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/multiple-caches.json @@ -0,0 +1,24 @@ +{ + "test-job": { + "stage": "build", + "cache": [ + { + "key": { + "files": ["Gemfile.lock"] + }, + "paths": ["vendor/ruby"] + }, + { + "key": { + "files": ["yarn.lock"] + }, + "paths": [".yarn-cache/"] + } + ], + "script": [ + "bundle install --path=vendor", + "yarn install --cache-folder .yarn-cache", + "echo Run tests..." + ] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json new file mode 100644 index 00000000000..1337e5e7bc8 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/retry.json @@ -0,0 +1,60 @@ +{ + "gitlab-ci-retry-int": { + "stage": "test", + "script": "rspec", + "retry": 2 + }, + "gitlab-ci-retry-object-no-max": { + "stage": "test", + "script": "rspec", + "retry": { + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-single-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": "runner_system_failure" + } + }, + "gitlab-ci-retry-object-multiple-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "stuck_or_timeout_failure"] + } + }, + "gitlab-ci-retry-object-multiple-when-dupes": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": ["runner_system_failure", "runner_system_failure"] + } + }, + "gitlab-ci-retry-object-all-when": { + "stage": "test", + "script": "rspec", + "retry": { + "max": 2, + "when": [ + "always", + "unknown_failure", + "script_failure", + "api_failure", + "stuck_or_timeout_failure", + "runner_system_failure", + "runner_unsupported", + "stale_schedule", + "job_execution_timeout", + "archived_failure", + "unmet_prerequisites", + "scheduler_failure", + "data_integrity_failure" + ] + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json new file mode 100644 index 00000000000..0e444a4ba62 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/terraform_report.json @@ -0,0 +1,50 @@ +{ + "image": { + "name": "registry.gitlab.com/gitlab-org/gitlab-build-images:terraform", + "entrypoint": [ + "/usr/bin/env", + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + ] + }, + "variables": { + "PLAN": "plan.tfplan", + "JSON_PLAN_FILE": "tfplan.json" + }, + "cache": { + "paths": [".terraform"] + }, + "before_script": [ + "alias convert_report=\"jq -r '([.resource_changes[]?.change.actions?]|flatten)|{\"create\":(map(select(.==\"create\"))|length),\"update\":(map(select(.==\"update\"))|length),\"delete\":(map(select(.==\"delete\"))|length)}'\"", + "terraform --version", + "terraform init" + ], + "stages": ["validate", "build", "test", "deploy"], + "validate": { + "stage": "validate", + "script": ["terraform validate"] + }, + "plan": { + "stage": "build", + "script": [ + "terraform plan -out=$PLAN", + "terraform show --json $PLAN | convert_report > $JSON_PLAN_FILE" + ], + "artifacts": { + "name": "plan", + "paths": ["$PLAN"], + "reports": { + "terraform": "$JSON_PLAN_FILE" + } + } + }, + "apply": { + "stage": "deploy", + "environment": { + "name": "production" + }, + "script": ["terraform apply -input=false $PLAN"], + "dependencies": ["plan"], + "when": "manual", + "only": ["master"] + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json new file mode 100644 index 00000000000..ce59b3fbbec --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables.json @@ -0,0 +1,22 @@ +{ + "variables": { + "DEPLOY_ENVIRONMENT": { + "value": "staging", + "description": "The deployment target. Change this variable to 'canary' or 'production' if needed." + } + }, + "gitlab-ci-variables-string": { + "stage": "test", + "script": ["true"], + "variables": { + "TEST_VAR": "String variable" + } + }, + "gitlab-ci-variables-integer": { + "stage": "test", + "script": ["true"], + "variables": { + "canonical": 685230 + } + } +} diff --git a/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json new file mode 100644 index 00000000000..87a9ec05b57 --- /dev/null +++ b/spec/frontend/editor/schema/ci/json_tests/positive_tests/variables_mix_string_and_user_input.json @@ -0,0 +1,10 @@ +{ + "variables": { + "SOME_STR": "--batch-mode --errors --fail-at-end --show-version", + "SOME_INT": 10, + "SOME_USER_INPUT_FLAG": { + "value": "flag value", + "description": "Some Flag!" + } + } +} diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml new file mode 100644 index 00000000000..ee533f54d3b --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml @@ -0,0 +1,15 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# invalid cache:when value +job1: + stage: prepare + cache: + when: 0 + +# invalid cache:when value +job2: + stage: prepare + cache: + when: 'never' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml new file mode 100644 index 00000000000..287150a765f --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/include.yml @@ -0,0 +1,17 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# missing file property +childPipeline: + stage: prepare + trigger: + include: + - project: 'my-group/my-pipeline-library' + +# missing project property +childPipeline2: + stage: prepare + trigger: + include: + - file: '.gitlab-ci.yml' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml new file mode 100644 index 00000000000..436c7d72699 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml @@ -0,0 +1,25 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 +stages: + - prepare + +# test for cache:when values +job1: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_success' + +job2: + stage: prepare + script: + - echo 'running job' + cache: + when: 'on_failure' + +job3: + stage: prepare + script: + - echo 'running job' + cache: + when: 'always' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml new file mode 100644 index 00000000000..2b29c24fa3c --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/filter.yml @@ -0,0 +1,18 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/79335 +deploy-template: + script: + - echo "hello world" + only: + - foo + except: + - bar + +# null value allowed +deploy-without-only: + extends: deploy-template + only: + +# null value allowed +deploy-without-except: + extends: deploy-template + except: diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml new file mode 100644 index 00000000000..3497be28058 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/include.yml @@ -0,0 +1,32 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70779 + +# test for include:rules +include: + - local: builds.yml + rules: + - if: '$INCLUDE_BUILDS == "true"' + when: always + +stages: + - prepare + +# test for trigger:include +childPipeline: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + +# accepts optional ref property +childPipeline2: + stage: prepare + script: + - echo 'creating pipeline...' + trigger: + include: + - project: 'my-group/my-pipeline-library' + file: '.gitlab-ci.yml' + ref: 'main' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml new file mode 100644 index 00000000000..27a199cff13 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/rules.yml @@ -0,0 +1,13 @@ +# Covers https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74164 + +# test for workflow:rules:changes and workflow:rules:exists +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "schedule"' + exists: + - Dockerfile + changes: + - Dockerfile + variables: + IS_A_FEATURE: 'true' + when: always diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js index f0fb4d1027c..6bf87f7b07f 100644 --- a/spec/frontend/environments/deploy_board_component_spec.js +++ b/spec/frontend/environments/deploy_board_component_spec.js @@ -23,9 +23,9 @@ describe('Deploy Board', () => { }); describe('with valid data', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent(); - nextTick(done); + return nextTick(); }); it('should render percentage with completion value provided', () => { @@ -127,14 +127,14 @@ describe('Deploy Board', () => { }); describe('with empty state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: false, isEmpty: true, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render the empty state', () => { @@ -146,14 +146,14 @@ describe('Deploy Board', () => { }); describe('with loading state', () => { - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ deployBoardData: {}, isLoading: true, isEmpty: false, logsPath, }); - nextTick(done); + return nextTick(); }); it('should render loading spinner', () => { @@ -163,7 +163,7 @@ describe('Deploy Board', () => { describe('has legend component', () => { let statuses = []; - beforeEach((done) => { + beforeEach(() => { wrapper = createComponent({ isLoading: false, isEmpty: false, @@ -171,7 +171,7 @@ describe('Deploy Board', () => { deployBoardData: deployBoardMockData, }); ({ statuses } = wrapper.vm); - nextTick(done); + return nextTick(); }); it('with all the possible statuses', () => { diff --git a/spec/frontend/environments/empty_state_spec.js b/spec/frontend/environments/empty_state_spec.js new file mode 100644 index 00000000000..974afc6d032 --- /dev/null +++ b/spec/frontend/environments/empty_state_spec.js @@ -0,0 +1,53 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { s__ } from '~/locale'; +import EmptyState from '~/environments/components/empty_state.vue'; +import { ENVIRONMENTS_SCOPE } from '~/environments/constants'; + +const HELP_PATH = '/help'; + +describe('~/environments/components/empty_state.vue', () => { + let wrapper; + + const createWrapper = ({ propsData = {} } = {}) => + mountExtended(EmptyState, { + propsData: { + scope: ENVIRONMENTS_SCOPE.AVAILABLE, + helpPath: HELP_PATH, + ...propsData, + }, + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows an empty state for available environments', () => { + wrapper = createWrapper(); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows an empty state for stopped environments', () => { + wrapper = createWrapper({ propsData: { scope: ENVIRONMENTS_SCOPE.STOPPED } }); + + const title = wrapper.findByRole('heading', { + name: s__("Environments|You don't have any stopped environments."), + }); + + expect(title.exists()).toBe(true); + }); + + it('shows a link to the the help path', () => { + wrapper = createWrapper(); + + const link = wrapper.findByRole('link', { + name: s__('Environments|How do I create an environment?'), + }); + + expect(link.attributes('href')).toBe(HELP_PATH); + }); +}); diff --git a/spec/frontend/environments/emtpy_state_spec.js b/spec/frontend/environments/emtpy_state_spec.js deleted file mode 100644 index 862d90e50dc..00000000000 --- a/spec/frontend/environments/emtpy_state_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import EmptyState from '~/environments/components/empty_state.vue'; - -describe('environments empty state', () => { - let vm; - - beforeEach(() => { - vm = shallowMount(EmptyState, { - propsData: { - helpPath: 'bar', - }, - }); - }); - - afterEach(() => { - vm.destroy(); - }); - - it('renders the empty state', () => { - expect(vm.find('.js-blank-state-title').text()).toEqual( - "You don't have any environments right now", - ); - }); -}); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 0b36d2a940d..0761d04229c 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; import { format } from 'timeago.js'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; @@ -44,10 +45,16 @@ describe('Environment item', () => { const findAutoStop = () => wrapper.find('.js-auto-stop'); const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]'); + const findLastDeployment = () => wrapper.find('[data-testid="environment-deployment-id-cell"]'); const findUpcomingDeploymentContent = () => wrapper.find('[data-testid="upcoming-deployment-content"]'); const findUpcomingDeploymentStatusLink = () => wrapper.find('[data-testid="upcoming-deployment-status-link"]'); + const findLastDeploymentAvatarLink = () => findLastDeployment().findComponent(GlAvatarLink); + const findLastDeploymentAvatar = () => findLastDeployment().findComponent(GlAvatar); + const findUpcomingDeploymentAvatarLink = () => + findUpcomingDeployment().findComponent(GlAvatarLink); + const findUpcomingDeploymentAvatar = () => findUpcomingDeployment().findComponent(GlAvatar); afterEach(() => { wrapper.destroy(); @@ -79,9 +86,19 @@ describe('Environment item', () => { describe('With user information', () => { it('should render user avatar with link to profile', () => { - expect(wrapper.find('.js-deploy-user-container').props('linkHref')).toEqual( - environment.last_deployment.user.web_url, - ); + const avatarLink = findLastDeploymentAvatarLink(); + const avatar = findLastDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.last_deployment.user; + + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); + expect(avatar.attributes()).toMatchObject({ + title: username, + alt: `${username}'s avatar`, + }); }); }); @@ -108,9 +125,16 @@ describe('Environment item', () => { describe('When the envionment has an upcoming deployment', () => { describe('When the upcoming deployment has a deployable', () => { it('should render the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should render a status icon with a link and tooltip', () => { @@ -139,10 +163,17 @@ describe('Environment item', () => { }); }); - it('should still renders the build ID and user', () => { - expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText( - '#27 by upcoming-username', - ); + it('should still render the build ID and user avatar', () => { + const avatarLink = findUpcomingDeploymentAvatarLink(); + const avatar = findUpcomingDeploymentAvatar(); + const { username, avatar_url, web_url } = environment.upcoming_deployment.user; + + expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText('#27 by'); + expect(avatarLink.attributes('href')).toBe(web_url); + expect(avatar.props()).toMatchObject({ + src: avatar_url, + entityName: username, + }); }); it('should not render the status icon', () => { @@ -383,7 +414,7 @@ describe('Environment item', () => { }); it('should hide non-folder properties', () => { - expect(wrapper.find('[data-testid="environment-deployment-id-cell"]').exists()).toBe(false); + expect(findLastDeployment().exists()).toBe(false); expect(wrapper.find('[data-testid="environment-build-cell"]').exists()).toBe(false); }); }); diff --git a/spec/frontend/environments/environment_table_spec.js b/spec/frontend/environments/environment_table_spec.js index c7582e4b06d..666e87c748e 100644 --- a/spec/frontend/environments/environment_table_spec.js +++ b/spec/frontend/environments/environment_table_spec.js @@ -122,7 +122,7 @@ describe('Environment table', () => { expect(wrapper.find('.deploy-board-icon').exists()).toBe(true); }); - it('should toggle deploy board visibility when arrow is clicked', (done) => { + it('should toggle deploy board visibility when arrow is clicked', async () => { const mockItem = { name: 'review', size: 1, @@ -142,7 +142,6 @@ describe('Environment table', () => { eventHub.$on('toggleDeployBoard', (env) => { expect(env.id).toEqual(mockItem.id); - done(); }); factory({ @@ -154,7 +153,7 @@ describe('Environment table', () => { }, }); - wrapper.find('.deploy-board-icon').trigger('click'); + await wrapper.find('.deploy-board-icon').trigger('click'); }); it('should set the environment to change and weight when a change canary weight event is recevied', async () => { diff --git a/spec/frontend/environments/graphql/mock_data.js b/spec/frontend/environments/graphql/mock_data.js index 1b7b35702de..7e436476a8f 100644 --- a/spec/frontend/environments/graphql/mock_data.js +++ b/spec/frontend/environments/graphql/mock_data.js @@ -543,6 +543,7 @@ export const resolvedEnvironment = { externalUrl: 'https://example.org', environmentType: 'review', nameWithoutType: 'hello', + tier: 'development', lastDeployment: { id: 78, iid: 24, @@ -551,6 +552,7 @@ export const resolvedEnvironment = { status: 'success', createdAt: '2022-01-07T15:47:27.415Z', deployedAt: '2022-01-07T15:47:32.450Z', + tierInYaml: 'staging', tag: false, isLast: true, user: { diff --git a/spec/frontend/environments/new_environment_item_spec.js b/spec/frontend/environments/new_environment_item_spec.js index 1d7a33fb95b..cf0c8a7e7ca 100644 --- a/spec/frontend/environments/new_environment_item_spec.js +++ b/spec/frontend/environments/new_environment_item_spec.js @@ -73,6 +73,34 @@ describe('~/environments/components/new_environment_item.vue', () => { expect(name.text()).toHaveLength(80); }); + describe('tier', () => { + it('displays the tier of the environment when defined in yaml', () => { + wrapper = createWrapper({ apolloProvider: createApolloProvider() }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.text()).toBe(resolvedEnvironment.lastDeployment.tierInYaml); + }); + + it('does not display the tier if not defined in yaml', () => { + const environment = { + ...resolvedEnvironment, + lastDeployment: { + ...resolvedEnvironment.lastDeployment, + tierInYaml: null, + }, + }; + wrapper = createWrapper({ + propsData: { environment }, + apolloProvider: createApolloProvider(), + }); + + const tier = wrapper.findByTitle(s__('Environment|Deployment tier')); + + expect(tier.exists()).toBe(false); + }); + }); + describe('url', () => { it('shows a link for the url if one is present', () => { wrapper = createWrapper({ apolloProvider: createApolloProvider() }); diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index aaaa1194a29..6bac21341a7 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -28,9 +28,9 @@ describe('Sentry common store actions', () => { const params = { endpoint, redirectUrl, status }; describe('updateStatus', () => { - it('should handle successful status update', (done) => { + it('should handle successful status update', async () => { mock.onPut().reply(200, {}); - testAction( + await testAction( actions.updateStatus, params, {}, @@ -41,20 +41,15 @@ describe('Sentry common store actions', () => { }, ], [], - () => { - done(); - expect(visitUrl).toHaveBeenCalledWith(redirectUrl); - }, ); + expect(visitUrl).toHaveBeenCalledWith(redirectUrl); }); - it('should handle unsuccessful status update', (done) => { + it('should handle unsuccessful status update', async () => { mock.onPut().reply(400, {}); - testAction(actions.updateStatus, params, {}, [], [], () => { - expect(visitUrl).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }); + await testAction(actions.updateStatus, params, {}, [], []); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 623cb82851d..a3a6f7cc309 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -28,10 +28,10 @@ describe('Sentry error details store actions', () => { describe('startPollingStacktrace', () => { const endpoint = '123/stacktrace'; - it('should commit SET_ERROR with received response', (done) => { + it('should commit SET_ERROR with received response', () => { const payload = { error: [1, 2, 3] }; mockedAdapter.onGet().reply(200, payload); - testAction( + return testAction( actions.startPollingStacktrace, { endpoint }, {}, @@ -40,37 +40,29 @@ describe('Sentry error details store actions', () => { { type: types.SET_LOADING_STACKTRACE, payload: false }, ], [], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mockedAdapter.onGet().reply(400); - testAction( + await testAction( actions.startPollingStacktrace, { endpoint }, {}, [{ type: types.SET_LOADING_STACKTRACE, payload: false }], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); - it('should not restart polling when receiving an empty 204 response', (done) => { + it('should not restart polling when receiving an empty 204 response', async () => { mockedRestart = jest.spyOn(Poll.prototype, 'restart'); mockedAdapter.onGet().reply(204); - testAction(actions.startPollingStacktrace, { endpoint }, {}, [], [], () => { - mockedRestart = jest.spyOn(Poll.prototype, 'restart'); - expect(mockedRestart).toHaveBeenCalledTimes(0); - done(); - }); + await testAction(actions.startPollingStacktrace, { endpoint }, {}, [], []); + mockedRestart = jest.spyOn(Poll.prototype, 'restart'); + expect(mockedRestart).toHaveBeenCalledTimes(0); }); }); }); diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 5465bde397c..7173f68bb96 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -20,11 +20,11 @@ describe('error tracking actions', () => { }); describe('startPolling', () => { - it('should start polling for data', (done) => { + it('should start polling for data', () => { const payload = { errors: [{ id: 1 }, { id: 2 }] }; mock.onGet().reply(httpStatusCodes.OK, payload); - testAction( + return testAction( actions.startPolling, {}, {}, @@ -35,16 +35,13 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [{ type: 'stopPolling' }], - () => { - done(); - }, ); }); - it('should show flash on API error', (done) => { + it('should show flash on API error', async () => { mock.onGet().reply(httpStatusCodes.BAD_REQUEST); - testAction( + await testAction( actions.startPolling, {}, {}, @@ -53,11 +50,8 @@ describe('error tracking actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - () => { - expect(createFlash).toHaveBeenCalledTimes(1); - done(); - }, ); + expect(createFlash).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/error_tracking_settings/store/actions_spec.js b/spec/frontend/error_tracking_settings/store/actions_spec.js index 1b9be042dd4..bcd816c2ae0 100644 --- a/spec/frontend/error_tracking_settings/store/actions_spec.js +++ b/spec/frontend/error_tracking_settings/store/actions_spec.js @@ -27,9 +27,9 @@ describe('error tracking settings actions', () => { refreshCurrentPage.mockClear(); }); - it('should request and transform the project list', (done) => { + it('should request and transform the project list', async () => { mock.onGet(TEST_HOST).reply(() => [200, { projects: projectList }]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -41,16 +41,13 @@ describe('error tracking settings actions', () => { payload: projectList.map(convertObjectPropsToCamelCase), }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onGet(`${TEST_HOST}.json`).reply(() => [400]); - testAction( + await testAction( actions.fetchProjects, null, state, @@ -61,27 +58,23 @@ describe('error tracking settings actions', () => { type: 'receiveProjectsError', }, ], - () => { - expect(mock.history.get.length).toBe(1); - done(); - }, ); + expect(mock.history.get.length).toBe(1); }); - it('should request projects correctly', (done) => { - testAction( + it('should request projects correctly', () => { + return testAction( actions.requestProjects, null, state, [{ type: types.SET_PROJECTS_LOADING, payload: true }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should receive projects correctly', (done) => { + it('should receive projects correctly', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsSuccess, testPayload, state, @@ -91,13 +84,12 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); - it('should handle errors when receiving projects', (done) => { + it('should handle errors when receiving projects', () => { const testPayload = []; - testAction( + return testAction( actions.receiveProjectsError, testPayload, state, @@ -107,7 +99,6 @@ describe('error tracking settings actions', () => { { type: types.SET_PROJECTS_LOADING, payload: false }, ], [], - done, ); }); }); @@ -126,18 +117,16 @@ describe('error tracking settings actions', () => { mock.restore(); }); - it('should save the page', (done) => { + it('should save the page', async () => { mock.onPatch(TEST_HOST).reply(200); - testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }], () => { - expect(mock.history.patch.length).toBe(1); - expect(refreshCurrentPage).toHaveBeenCalled(); - done(); - }); + await testAction(actions.updateSettings, null, state, [], [{ type: 'requestSettings' }]); + expect(mock.history.patch.length).toBe(1); + expect(refreshCurrentPage).toHaveBeenCalled(); }); - it('should handle a server error', (done) => { + it('should handle a server error', async () => { mock.onPatch(TEST_HOST).reply(400); - testAction( + await testAction( actions.updateSettings, null, state, @@ -149,57 +138,50 @@ describe('error tracking settings actions', () => { payload: new Error('Request failed with status code 400'), }, ], - () => { - expect(mock.history.patch.length).toBe(1); - done(); - }, ); + expect(mock.history.patch.length).toBe(1); }); - it('should request to save the page', (done) => { - testAction( + it('should request to save the page', () => { + return testAction( actions.requestSettings, null, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: true }], [], - done, ); }); - it('should handle errors when requesting to save the page', (done) => { - testAction( + it('should handle errors when requesting to save the page', () => { + return testAction( actions.receiveSettingsError, {}, state, [{ type: types.UPDATE_SETTINGS_LOADING, payload: false }], [], - done, ); }); }); describe('generic actions to update the store', () => { const testData = 'test'; - it('should reset the `connect success` flag when updating the api host', (done) => { - testAction( + it('should reset the `connect success` flag when updating the api host', () => { + return testAction( actions.updateApiHost, testData, state, [{ type: types.UPDATE_API_HOST, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); - it('should reset the `connect success` flag when updating the token', (done) => { - testAction( + it('should reset the `connect success` flag when updating the token', () => { + return testAction( actions.updateToken, testData, state, [{ type: types.UPDATE_TOKEN, payload: testData }, { type: types.RESET_CONNECT }], [], - done, ); }); diff --git a/spec/frontend/feature_flags/store/edit/actions_spec.js b/spec/frontend/feature_flags/store/edit/actions_spec.js index 12fccd79170..b6114cb0c9f 100644 --- a/spec/frontend/feature_flags/store/edit/actions_spec.js +++ b/spec/frontend/feature_flags/store/edit/actions_spec.js @@ -40,7 +40,7 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagSuccess ', () => { const featureFlag = { name: 'name', description: 'description', @@ -57,7 +57,7 @@ describe('Feature flags Edit Module actions', () => { }; mock.onPut(mockedState.endpoint, mapStrategiesToRails(featureFlag)).replyOnce(200); - testAction( + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -70,16 +70,15 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestUpdateFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onPut(`${TEST_HOST}/endpoint.json`).replyOnce(500, { message: [] }); - testAction( + return testAction( updateFeatureFlag, { name: 'feature_flag', @@ -97,28 +96,26 @@ describe('Feature flags Edit Module actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestUpdateFeatureFlag', () => { - it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_UPDATE_FEATURE_FLAG mutation', () => { + return testAction( requestUpdateFeatureFlag, null, mockedState, [{ type: types.REQUEST_UPDATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveUpdateFeatureFlagSuccess', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveUpdateFeatureFlagSuccess, null, mockedState, @@ -128,20 +125,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('receiveUpdateFeatureFlagError', () => { - it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_UPDATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveUpdateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_UPDATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); @@ -159,10 +154,10 @@ describe('Feature flags Edit Module actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', (done) => { + it('dispatches requestFeatureFlag and receiveFeatureFlagSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, { id: 1 }); - testAction( + return testAction( fetchFeatureFlag, { id: 1 }, mockedState, @@ -176,16 +171,15 @@ describe('Feature flags Edit Module actions', () => { payload: { id: 1 }, }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', (done) => { + it('dispatches requestFeatureFlag and receiveUpdateFeatureFlagError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlag, null, mockedState, @@ -198,41 +192,38 @@ describe('Feature flags Edit Module actions', () => { type: 'receiveFeatureFlagError', }, ], - done, ); }); }); }); describe('requestFeatureFlag', () => { - it('should commit REQUEST_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_FEATURE_FLAG mutation', () => { + return testAction( requestFeatureFlag, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveFeatureFlagSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagSuccess, { id: 1 }, mockedState, [{ type: types.RECEIVE_FEATURE_FLAG_SUCCESS, payload: { id: 1 } }], [], - done, ); }); }); describe('receiveFeatureFlagError', () => { - it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveFeatureFlagError, null, mockedState, @@ -242,20 +233,18 @@ describe('Feature flags Edit Module actions', () => { }, ], [], - done, ); }); }); describe('toggelActive', () => { - it('should commit TOGGLE_ACTIVE mutation', (done) => { - testAction( + it('should commit TOGGLE_ACTIVE mutation', () => { + return testAction( toggleActive, true, mockedState, [{ type: types.TOGGLE_ACTIVE, payload: true }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/index/actions_spec.js b/spec/frontend/feature_flags/store/index/actions_spec.js index a59f99f538c..ce62c3b0473 100644 --- a/spec/frontend/feature_flags/store/index/actions_spec.js +++ b/spec/frontend/feature_flags/store/index/actions_spec.js @@ -32,14 +32,13 @@ describe('Feature flags actions', () => { }); describe('setFeatureFlagsOptions', () => { - it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_FEATURE_FLAGS_OPTIONS mutation', () => { + return testAction( setFeatureFlagsOptions, { page: '1', scope: 'all' }, mockedState, [{ type: types.SET_FEATURE_FLAGS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -57,10 +56,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, getRequestData, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -74,16 +73,15 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', (done) => { + it('dispatches requestFeatureFlags and receiveFeatureFlagsError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( fetchFeatureFlags, null, mockedState, @@ -96,28 +94,26 @@ describe('Feature flags actions', () => { type: 'receiveFeatureFlagsError', }, ], - done, ); }); }); }); describe('requestFeatureFlags', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( requestFeatureFlags, null, mockedState, [{ type: types.REQUEST_FEATURE_FLAGS }], [], - done, ); }); }); describe('receiveFeatureFlagsSuccess', () => { - it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_SUCCESS mutation', () => { + return testAction( receiveFeatureFlagsSuccess, { data: getRequestData, headers: {} }, mockedState, @@ -128,20 +124,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveFeatureFlagsError', () => { - it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_FEATURE_FLAGS_ERROR mutation', () => { + return testAction( receiveFeatureFlagsError, null, mockedState, [{ type: types.RECEIVE_FEATURE_FLAGS_ERROR }], [], - done, ); }); }); @@ -159,10 +153,10 @@ describe('Feature flags actions', () => { }); describe('success', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdSuccess ', () => { mock.onPost(`${TEST_HOST}/endpoint.json`).replyOnce(200, rotateData, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -176,16 +170,15 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', (done) => { + it('dispatches requestRotateInstanceId and receiveRotateInstanceIdError ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`, {}).replyOnce(500, {}); - testAction( + return testAction( rotateInstanceId, null, mockedState, @@ -198,28 +191,26 @@ describe('Feature flags actions', () => { type: 'receiveRotateInstanceIdError', }, ], - done, ); }); }); }); describe('requestRotateInstanceId', () => { - it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', (done) => { - testAction( + it('should commit REQUEST_ROTATE_INSTANCE_ID mutation', () => { + return testAction( requestRotateInstanceId, null, mockedState, [{ type: types.REQUEST_ROTATE_INSTANCE_ID }], [], - done, ); }); }); describe('receiveRotateInstanceIdSuccess', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_SUCCESS mutation', () => { + return testAction( receiveRotateInstanceIdSuccess, { data: rotateData, headers: {} }, mockedState, @@ -230,20 +221,18 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('receiveRotateInstanceIdError', () => { - it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ROTATE_INSTANCE_ID_ERROR mutation', () => { + return testAction( receiveRotateInstanceIdError, null, mockedState, [{ type: types.RECEIVE_ROTATE_INSTANCE_ID_ERROR }], [], - done, ); }); }); @@ -262,10 +251,10 @@ describe('Feature flags actions', () => { mock.restore(); }); describe('success', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(200, featureFlag, {}); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -280,15 +269,15 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagSuccess', }, ], - done, ); }); }); + describe('error', () => { - it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', (done) => { + it('dispatches updateFeatureFlag and receiveUpdateFeatureFlagSuccess', () => { mock.onPut(featureFlag.update_path).replyOnce(500); - testAction( + return testAction( toggleFeatureFlag, featureFlag, mockedState, @@ -303,7 +292,6 @@ describe('Feature flags actions', () => { type: 'receiveUpdateFeatureFlagError', }, ], - done, ); }); }); @@ -315,8 +303,8 @@ describe('Feature flags actions', () => { })); }); - it('commits UPDATE_FEATURE_FLAG with the given flag', (done) => { - testAction( + it('commits UPDATE_FEATURE_FLAG with the given flag', () => { + return testAction( updateFeatureFlag, featureFlag, mockedState, @@ -327,7 +315,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -338,8 +325,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_SUCCESS with the given flag', () => { + return testAction( receiveUpdateFeatureFlagSuccess, featureFlag, mockedState, @@ -350,7 +337,6 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); @@ -361,8 +347,8 @@ describe('Feature flags actions', () => { })); }); - it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', (done) => { - testAction( + it('commits RECEIVE_UPDATE_FEATURE_FLAG_ERROR with the given flag id', () => { + return testAction( receiveUpdateFeatureFlagError, featureFlag.id, mockedState, @@ -373,22 +359,20 @@ describe('Feature flags actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, mockedState, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/feature_flags/store/new/actions_spec.js b/spec/frontend/feature_flags/store/new/actions_spec.js index 7900b200eb2..1dcd2da1d93 100644 --- a/spec/frontend/feature_flags/store/new/actions_spec.js +++ b/spec/frontend/feature_flags/store/new/actions_spec.js @@ -33,7 +33,7 @@ describe('Feature flags New Module Actions', () => { }); describe('success', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagSuccess ', () => { const actionParams = { name: 'name', description: 'description', @@ -50,7 +50,7 @@ describe('Feature flags New Module Actions', () => { }; mock.onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)).replyOnce(200); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -63,13 +63,12 @@ describe('Feature flags New Module Actions', () => { type: 'receiveCreateFeatureFlagSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', (done) => { + it('dispatches requestCreateFeatureFlag and receiveCreateFeatureFlagError ', () => { const actionParams = { name: 'name', description: 'description', @@ -88,7 +87,7 @@ describe('Feature flags New Module Actions', () => { .onPost(mockedState.endpoint, mapStrategiesToRails(actionParams)) .replyOnce(500, { message: [] }); - testAction( + return testAction( createFeatureFlag, actionParams, mockedState, @@ -102,28 +101,26 @@ describe('Feature flags New Module Actions', () => { payload: { message: [] }, }, ], - done, ); }); }); }); describe('requestCreateFeatureFlag', () => { - it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', (done) => { - testAction( + it('should commit REQUEST_CREATE_FEATURE_FLAG mutation', () => { + return testAction( requestCreateFeatureFlag, null, mockedState, [{ type: types.REQUEST_CREATE_FEATURE_FLAG }], [], - done, ); }); }); describe('receiveCreateFeatureFlagSuccess', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_SUCCESS mutation', () => { + return testAction( receiveCreateFeatureFlagSuccess, null, mockedState, @@ -133,20 +130,18 @@ describe('Feature flags New Module Actions', () => { }, ], [], - done, ); }); }); describe('receiveCreateFeatureFlagError', () => { - it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_CREATE_FEATURE_FLAG_ERROR mutation', () => { + return testAction( receiveCreateFeatureFlagError, 'There was an error', mockedState, [{ type: types.RECEIVE_CREATE_FEATURE_FLAG_ERROR, payload: 'There was an error' }], [], - done, ); }); }); diff --git a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js index 88b3fc236e4..212b9ffc8f9 100644 --- a/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js +++ b/spec/frontend/filtered_search/droplab/plugins/ajax_filter_spec.js @@ -38,35 +38,25 @@ describe('AjaxFilter', () => { dummyList.list.appendChild(dynamicList); }); - it('calls onLoadingFinished after loading data', (done) => { + it('calls onLoadingFinished after loading data', async () => { ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.resolve(dummyData); }; - AjaxFilter.trigger() - .then(() => { - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); - }) - .then(done) - .catch(done.fail); + await AjaxFilter.trigger(); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(1); }); - it('does not call onLoadingFinished if Ajax call fails', (done) => { + it('does not call onLoadingFinished if Ajax call fails', async () => { const dummyError = new Error('My dummy is sick! :-('); ajaxSpy = (url) => { expect(url).toBe('dummy endpoint?dummy search key='); return Promise.reject(dummyError); }; - AjaxFilter.trigger() - .then(done.fail) - .catch((error) => { - expect(error).toBe(dummyError); - expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await expect(AjaxFilter.trigger()).rejects.toEqual(dummyError); + expect(dummyConfig.onLoadingFinished.mock.calls.length).toBe(0); }); }); }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 83e7f6c9b3f..911a507af4c 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -190,43 +190,40 @@ describe('Filtered Search Manager', () => { const defaultParams = '?scope=all'; const defaultState = '&state=opened'; - it('should search with a single word', (done) => { + it('should search with a single word', () => { initializeManager(); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=searchTerm`); - done(); }); manager.search(); }); - it('sets default state', (done) => { + it('sets default state', () => { initializeManager({ useDefaultState: true }); input.value = 'searchTerm'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}${defaultState}&search=searchTerm`); - done(); }); manager.search(); }); - it('should search with multiple words', (done) => { + it('should search with multiple words', () => { initializeManager(); input.value = 'awesome search terms'; visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); - done(); }); manager.search(); }); - it('should search with special characters', (done) => { + it('should search with special characters', () => { initializeManager(); input.value = '~!@#$%^&*()_+{}:<>,.?/'; @@ -234,13 +231,12 @@ describe('Filtered Search Manager', () => { expect(url).toEqual( `${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`, ); - done(); }); manager.search(); }); - it('should use replacement URL for condition', (done) => { + it('should use replacement URL for condition', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '13', true), @@ -248,7 +244,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&milestone_title=replaced`); - done(); }); manager.filteredSearchTokenKeys.conditions.push({ @@ -261,7 +256,7 @@ describe('Filtered Search Manager', () => { manager.search(); }); - it('removes duplicated tokens', (done) => { + it('removes duplicated tokens', () => { initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} @@ -270,7 +265,6 @@ describe('Filtered Search Manager', () => { visitUrl.mockImplementation((url) => { expect(url).toEqual(`${defaultParams}&label_name[]=bug`); - done(); }); manager.search(); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js index dfa53652eb1..426a60df427 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -18,53 +18,47 @@ describe('RecentSearchesService', () => { jest.spyOn(RecentSearchesService, 'isAvailable').mockReturnValue(true); }); - it('should default to empty array', (done) => { + it('should default to empty array', () => { const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual([]); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual([]); + }); }); - it('should reject when unable to parse', (done) => { + it('should reject when unable to parse', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('fail'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then(done.fail) + return fetchItemsPromise + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(SyntaxError)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should reject when service is unavailable', (done) => { + it('should reject when service is unavailable', () => { RecentSearchesService.isAvailable.mockReturnValue(false); - service + return service .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((error) => { expect(error).toEqual(expect.any(Error)); - }) - .then(done) - .catch(done.fail); + }); }); - it('should return items from localStorage', (done) => { + it('should return items from localStorage', () => { jest.spyOn(localStorage, 'getItem').mockReturnValue('["foo", "bar"]'); const fetchItemsPromise = service.fetch(); - fetchItemsPromise - .then((items) => { - expect(items).toEqual(['foo', 'bar']); - }) - .then(done) - .catch(done.fail); + return fetchItemsPromise.then((items) => { + expect(items).toEqual(['foo', 'bar']); + }); }); describe('if .isAvailable returns `false`', () => { @@ -74,16 +68,16 @@ describe('RecentSearchesService', () => { jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {}); }); - it('should not call .getItem', (done) => { - RecentSearchesService.prototype + it('should not call .getItem', () => { + return RecentSearchesService.prototype .fetch() - .then(done.fail) + .then(() => { + throw new Error(); + }) .catch((err) => { expect(err).toEqual(new RecentSearchesServiceError()); expect(localStorage.getItem).not.toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + }); }); }); }); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 8ac5b6fbea6..bf526a8d371 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -46,7 +46,7 @@ describe('Filtered Search Visual Tokens', () => { jest.spyOn(UsersCache, 'retrieve').mockImplementation((username) => usersCacheSpy(username)); }); - it('ignores error if UsersCache throws', (done) => { + it('ignores error if UsersCache throws', async () => { const dummyError = new Error('Earth rotated backwards'); const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; @@ -55,16 +55,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.reject(dummyError); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(createFlash.mock.calls.length).toBe(0); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(createFlash.mock.calls.length).toBe(0); }); - it('does nothing if user cannot be found', (done) => { + it('does nothing if user cannot be found', async () => { const { subject, tokenValueContainer, tokenValueElement } = findElements(authorToken); const tokenValue = tokenValueElement.innerText; usersCacheSpy = (username) => { @@ -72,16 +67,11 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(undefined); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueElement.innerText).toBe(tokenValue); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueElement.innerText).toBe(tokenValue); }); - it('replaces author token with avatar and display name', (done) => { + it('replaces author token with avatar and display name', async () => { const dummyUser = { name: 'Important Person', avatar_url: 'https://host.invalid/mypics/avatar.png', @@ -93,21 +83,16 @@ describe('Filtered Search Visual Tokens', () => { return Promise.resolve(dummyUser); }; - subject - .updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) - .then(() => { - expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); - expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); - const avatar = tokenValueElement.querySelector('img.avatar'); - - expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); - expect(avatar.getAttribute('alt')).toBe(''); - }) - .then(done) - .catch(done.fail); + await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); + expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue); + expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); + const avatar = tokenValueElement.querySelector('img.avatar'); + + expect(avatar.getAttribute('src')).toBe(dummyUser.avatar_url); + expect(avatar.getAttribute('alt')).toBe(''); }); - it('escapes user name when creating token', (done) => { + it('escapes user name when creating token', async () => { const dummyUser = { name: ' others.`; + const TEXT_CONTAIN_SEARCH_TERM = `This text contain ${SEARCH_TERM}.`; + const TEXT_WITH_SIBLING_ELEMENTS = `${SEARCH_TERM} Learn more.`; let wrapper; @@ -42,13 +44,7 @@ describe('search_settings/components/search_settings.vue', () => { }); }; - const matchParentElement = () => { - const highlightedList = Array.from(document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)); - return highlightedList.map((element) => { - return element.parentNode; - }); - }; - + const findMatchSiblingElement = () => document.querySelector(`[data-testid="sibling"]`); const findSearchBox = () => wrapper.find(GlSearchBoxByType); const search = (term) => { findSearchBox().vm.$emit('input', term); @@ -56,7 +52,7 @@ describe('search_settings/components/search_settings.vue', () => { const clearSearch = () => search(''); beforeEach(() => { - setFixtures(` + setHTMLFixture(`
      @@ -69,6 +65,7 @@ describe('search_settings/components/search_settings.vue', () => {
      ${SEARCH_TERM} ${TEXT_CONTAIN_SEARCH_TERM} + ${TEXT_WITH_SIBLING_ELEMENTS}
      @@ -99,7 +96,7 @@ describe('search_settings/components/search_settings.vue', () => { it('highlight elements that match the search term', () => { search(SEARCH_TERM); - expect(highlightedElementsCount()).toBe(2); + expect(highlightedElementsCount()).toBe(3); }); it('highlight only search term and not the whole line', () => { @@ -108,14 +105,26 @@ describe('search_settings/components/search_settings.vue', () => { expect(highlightedTextNodes()).toBe(true); }); - it('prevents search xss', () => { + // Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/350494 + it('preserves elements that are siblings of matches', () => { + const snapshot = ` + + Learn more + + `; + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + search(SEARCH_TERM); - const parentNodeList = matchParentElement(); - parentNodeList.forEach((element) => { - const scriptElement = element.getElementsByTagName('script'); - expect(scriptElement.length).toBe(0); - }); + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); + + clearSearch(); + + expect(findMatchSiblingElement()).toMatchInlineSnapshot(snapshot); }); describe('default', () => { diff --git a/spec/frontend/security_configuration/components/app_spec.js b/spec/frontend/security_configuration/components/app_spec.js index 963577fa763..9a18cb636b2 100644 --- a/spec/frontend/security_configuration/components/app_spec.js +++ b/spec/frontend/security_configuration/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlTab, GlTabs } from '@gitlab/ui'; +import { GlTab, GlTabs, GlLink } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; @@ -33,6 +33,7 @@ const autoDevopsHelpPagePath = '/autoDevopsHelpPagePath'; const autoDevopsPath = '/autoDevopsPath'; const gitlabCiHistoryPath = 'test/historyPath'; const projectFullPath = 'namespace/project'; +const vulnerabilityTrainingDocsPath = 'user/application_security/vulnerabilities/index'; useLocalStorageSpy(); @@ -55,6 +56,7 @@ describe('App component', () => { autoDevopsHelpPagePath, autoDevopsPath, projectFullPath, + vulnerabilityTrainingDocsPath, glFeatures: { secureVulnerabilityTraining, }, @@ -107,6 +109,7 @@ describe('App component', () => { const findUpgradeBanner = () => wrapper.findComponent(UpgradeBanner); const findAutoDevopsAlert = () => wrapper.findComponent(AutoDevopsAlert); const findAutoDevopsEnabledAlert = () => wrapper.findComponent(AutoDevopsEnabledAlert); + const findVulnerabilityManagementTab = () => wrapper.findByTestId('vulnerability-management-tab'); const securityFeaturesMock = [ { @@ -454,9 +457,14 @@ describe('App component', () => { }); it('renders security training description', () => { - const vulnerabilityManagementTab = wrapper.findByTestId('vulnerability-management-tab'); + expect(findVulnerabilityManagementTab().text()).toContain(i18n.securityTrainingDescription); + }); + + it('renders link to help docs', () => { + const trainingLink = findVulnerabilityManagementTab().findComponent(GlLink); - expect(vulnerabilityManagementTab.text()).toContain(i18n.securityTrainingDescription); + expect(trainingLink.text()).toBe('Learn more about vulnerability training'); + expect(trainingLink.attributes('href')).toBe(vulnerabilityTrainingDocsPath); }); }); diff --git a/spec/frontend/security_configuration/components/feature_card_badge_spec.js b/spec/frontend/security_configuration/components/feature_card_badge_spec.js new file mode 100644 index 00000000000..dcde0808fa4 --- /dev/null +++ b/spec/frontend/security_configuration/components/feature_card_badge_spec.js @@ -0,0 +1,40 @@ +import { mount } from '@vue/test-utils'; +import { GlBadge, GlTooltip } from '@gitlab/ui'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; + +describe('Feature card badge component', () => { + let wrapper; + + const createComponent = (propsData) => { + wrapper = extendedWrapper( + mount(FeatureCardBadge, { + propsData, + }), + ); + }; + + const findTooltip = () => wrapper.findComponent(GlTooltip); + const findBadge = () => wrapper.findComponent(GlBadge); + + describe('tooltip render', () => { + describe.each` + context | badge | badgeHref + ${'href on a badge object'} | ${{ tooltipText: 'test', badgeHref: 'href' }} | ${undefined} + ${'href as property '} | ${{ tooltipText: null, badgeHref: '' }} | ${'link'} + ${'default href no property on badge or component'} | ${{ tooltipText: null, badgeHref: '' }} | ${undefined} + `('given $context', ({ badge, badgeHref }) => { + beforeEach(() => { + createComponent({ badge, badgeHref }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findTooltip().exists()).toBe(Boolean(badge && badge.tooltipText)); + }); + + it('should render correct link if link is provided', () => { + expect(findBadge().attributes().href).toEqual(badgeHref); + }); + }); + }); +}); diff --git a/spec/frontend/security_configuration/components/feature_card_spec.js b/spec/frontend/security_configuration/components/feature_card_spec.js index f0d902bf9fe..d10722be8ea 100644 --- a/spec/frontend/security_configuration/components/feature_card_spec.js +++ b/spec/frontend/security_configuration/components/feature_card_spec.js @@ -2,6 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import FeatureCard from '~/security_configuration/components/feature_card.vue'; +import FeatureCardBadge from '~/security_configuration/components/feature_card_badge.vue'; import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue'; import { REPORT_TYPE_SAST } from '~/vue_shared/security_reports/constants'; import { makeFeature } from './utils'; @@ -16,6 +17,7 @@ describe('FeatureCard component', () => { propsData, stubs: { ManageViaMr: true, + FeatureCardBadge: true, }, }), ); @@ -24,6 +26,8 @@ describe('FeatureCard component', () => { const findLinks = ({ text, href }) => wrapper.findAll(`a[href="${href}"]`).filter((link) => link.text() === text); + const findBadge = () => wrapper.findComponent(FeatureCardBadge); + const findEnableLinks = () => findLinks({ text: `Enable ${feature.shortName ?? feature.name}`, @@ -262,5 +266,28 @@ describe('FeatureCard component', () => { }); }); }); + + describe('information badge', () => { + describe.each` + context | available | badge + ${'available feature with badge'} | ${true} | ${{ text: 'test' }} + ${'unavailable feature without badge'} | ${false} | ${null} + ${'available feature without badge'} | ${true} | ${null} + ${'unavailable feature with badge'} | ${false} | ${{ text: 'test' }} + ${'available feature with empty badge'} | ${false} | ${{}} + `('given $context', ({ available, badge }) => { + beforeEach(() => { + feature = makeFeature({ + available, + badge, + }); + createComponent({ feature }); + }); + + it('should show badge when badge given in configuration and available', () => { + expect(findBadge().exists()).toBe(Boolean(available && badge && badge.text)); + }); + }); + }); }); }); diff --git a/spec/frontend/security_configuration/components/training_provider_list_spec.js b/spec/frontend/security_configuration/components/training_provider_list_spec.js index b8c1bef0ddd..309a9cd4cd6 100644 --- a/spec/frontend/security_configuration/components/training_provider_list_spec.js +++ b/spec/frontend/security_configuration/components/training_provider_list_spec.js @@ -1,5 +1,13 @@ import * as Sentry from '@sentry/browser'; -import { GlAlert, GlLink, GlToggle, GlCard, GlSkeletonLoader, GlIcon } from '@gitlab/ui'; +import { + GlAlert, + GlLink, + GlFormRadio, + GlToggle, + GlCard, + GlSkeletonLoader, + GlIcon, +} from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -87,7 +95,7 @@ describe('TrainingProviderList component', () => { const findLinks = () => wrapper.findAllComponents(GlLink); const findToggles = () => wrapper.findAllComponents(GlToggle); const findFirstToggle = () => findToggles().at(0); - const findPrimaryProviderRadios = () => wrapper.findAllByTestId('primary-provider-radio'); + const findPrimaryProviderRadios = () => wrapper.findAllComponents(GlFormRadio); const findLoader = () => wrapper.findComponent(GlSkeletonLoader); const findErrorAlert = () => wrapper.findComponent(GlAlert); const findLogos = () => wrapper.findAllByTestId('provider-logo'); @@ -177,8 +185,8 @@ describe('TrainingProviderList component', () => { const primaryProviderRadioForCurrentCard = findPrimaryProviderRadios().at(index); // if the given provider is not enabled it should not be possible select it as primary - expect(primaryProviderRadioForCurrentCard.find('input').attributes('disabled')).toBe( - isEnabled ? undefined : 'disabled', + expect(primaryProviderRadioForCurrentCard.attributes('disabled')).toBe( + isEnabled ? undefined : 'true', ); expect(primaryProviderRadioForCurrentCard.text()).toBe( diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 6bcb2a713ea..59ee87c4a02 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -16,27 +16,25 @@ describe('self monitor actions', () => { }); describe('setSelfMonitor', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.setSelfMonitor, null, state, [{ type: types.SET_ENABLED, payload: null }], [], - done, ); }); }); describe('resetAlert', () => { - it('commits the SET_ENABLED mutation', (done) => { - testAction( + it('commits the SET_ENABLED mutation', () => { + return testAction( actions.resetAlert, null, state, [{ type: types.SET_SHOW_ALERT, payload: false }], [], - done, ); }); }); @@ -54,8 +52,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestCreateProject, null, state, @@ -71,12 +69,11 @@ describe('self monitor actions', () => { payload: '123', }, ], - done, ); }); - it('dispatches success with project path', (done) => { - testAction( + it('dispatches success with project path', () => { + return testAction( actions.requestCreateProjectStatus, null, state, @@ -87,7 +84,6 @@ describe('self monitor actions', () => { payload: { project_full_path: '/self-monitor-url' }, }, ], - done, ); }); }); @@ -98,8 +94,8 @@ describe('self monitor actions', () => { mock.onPost(state.createProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestCreateProject, null, state, @@ -115,14 +111,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestCreateProjectSuccess', () => { - it('should commit the received data', (done) => { - testAction( + it('should commit the received data', () => { + return testAction( actions.requestCreateProjectSuccess, { project_full_path: '/self-monitor-url' }, state, @@ -146,7 +141,6 @@ describe('self monitor actions', () => { type: 'setSelfMonitor', }, ], - done, ); }); }); @@ -165,8 +159,8 @@ describe('self monitor actions', () => { }); }); - it('dispatches status request with job data', (done) => { - testAction( + it('dispatches status request with job data', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -182,12 +176,11 @@ describe('self monitor actions', () => { payload: '456', }, ], - done, ); }); - it('dispatches success with status', (done) => { - testAction( + it('dispatches success with status', () => { + return testAction( actions.requestDeleteProjectStatus, null, state, @@ -198,7 +191,6 @@ describe('self monitor actions', () => { payload: { status: 'success' }, }, ], - done, ); }); }); @@ -209,8 +201,8 @@ describe('self monitor actions', () => { mock.onDelete(state.deleteProjectEndpoint).reply(500); }); - it('dispatches error', (done) => { - testAction( + it('dispatches error', () => { + return testAction( actions.requestDeleteProject, null, state, @@ -226,14 +218,13 @@ describe('self monitor actions', () => { payload: new Error('Request failed with status code 500'), }, ], - done, ); }); }); describe('requestDeleteProjectSuccess', () => { - it('should commit mutations to remove previously set data', (done) => { - testAction( + it('should commit mutations to remove previously set data', () => { + return testAction( actions.requestDeleteProjectSuccess, null, state, @@ -252,7 +243,6 @@ describe('self monitor actions', () => { { type: types.SET_LOADING, payload: false }, ], [], - done, ); }); }); diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap index f57b9418be5..0f4dfdf8a75 100644 --- a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -3,7 +3,7 @@ exports[`EmptyStateComponent should render content 1`] = ` "
      -
      \\"\\"
      +
      \\"\\"
      diff --git a/spec/frontend/serverless/store/actions_spec.js b/spec/frontend/serverless/store/actions_spec.js index 61b9bd121af..5fbecf081a6 100644 --- a/spec/frontend/serverless/store/actions_spec.js +++ b/spec/frontend/serverless/store/actions_spec.js @@ -7,13 +7,22 @@ import { mockServerlessFunctions, mockMetrics } from '../mock_data'; import { adjustMetricQuery } from '../utils'; describe('ServerlessActions', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + describe('fetchFunctions', () => { - it('should successfully fetch functions', (done) => { + it('should successfully fetch functions', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions)); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, @@ -22,68 +31,49 @@ describe('ServerlessActions', () => { { type: 'requestFunctionsLoading' }, { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions }, ], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully retry', (done) => { + it('should successfully retry', () => { const endpoint = '/functions'; - const mock = new MockAdapter(axios); mock .onGet(endpoint) .reply(() => new Promise((resolve) => setTimeout(() => resolve(200), Infinity))); - testAction( + return testAction( fetchFunctions, { functionsPath: endpoint }, {}, [], [{ type: 'requestFunctionsLoading' }], - () => { - mock.restore(); - done(); - }, ); }); }); describe('fetchMetrics', () => { - it('should return no prometheus', (done) => { + it('should return no prometheus', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: false }, {}, [], [{ type: 'receiveMetricsNoPrometheus' }], - () => { - mock.restore(); - done(); - }, ); }); - it('should successfully fetch metrics', (done) => { + it('should successfully fetch metrics', () => { const endpoint = '/metrics'; - const mock = new MockAdapter(axios); mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics)); - testAction( + return testAction( fetchMetrics, { metricsPath: endpoint, hasPrometheus: true }, {}, [], [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }], - () => { - mock.restore(); - done(); - }, ); }); }); diff --git a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js index c105810e11c..0b672cbc93e 100644 --- a/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js +++ b/spec/frontend/set_status_modal/set_status_modal_wrapper_spec.js @@ -26,7 +26,7 @@ describe('SetStatusModalWrapper', () => { defaultEmoji, }; - const createComponent = (props = {}, improvedEmojiPicker = false) => { + const createComponent = (props = {}) => { return shallowMount(SetStatusModalWrapper, { propsData: { ...defaultProps, @@ -35,19 +35,15 @@ describe('SetStatusModalWrapper', () => { mocks: { $toast, }, - provide: { - glFeatures: { improvedEmojiPicker }, - }, }); }; 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 findClearStatusAtMessage = () => wrapper.find('[data-testid="clear-status-at-message"]'); + const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); const initModal = async ({ mockOnUpdateSuccess = true, mockOnUpdateFailure = true } = {}) => { const modal = findModal(); @@ -95,12 +91,6 @@ describe('SetStatusModalWrapper', () => { 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(); - }); - it('displays the clear status at dropdown', () => { expect(wrapper.find('[data-testid="clear-status-at-dropdown"]').exists()).toBe(true); }); @@ -108,16 +98,6 @@ describe('SetStatusModalWrapper', () => { it('does not display the clear status at message', () => { expect(findClearStatusAtMessage().exists()).toBe(false); }); - }); - - describe('improvedEmojiPicker is true', () => { - const getEmojiPicker = () => wrapper.findComponent(EmojiPicker); - - beforeEach(async () => { - await initEmojiMock(); - wrapper = createComponent({}, true); - return initModal(); - }); it('renders emoji picker dropdown with custom positioning', () => { expect(getEmojiPicker().props()).toMatchObject({ @@ -147,10 +127,6 @@ describe('SetStatusModalWrapper', () => { 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', () => { @@ -163,22 +139,6 @@ describe('SetStatusModalWrapper', () => { 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 () => { - await initEmojiMock(); - wrapper = createComponent({ currentEmoji: '', currentMessage: '' }); - return initModal(); - }); - - it('shows the placeholder emoji', () => { - expect(findNoEmojiPlaceholder().isVisible()).toBe(true); - }); - }); }); describe('with currentClearStatusAfter set', () => { diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 49148123a1c..8b9a11056f2 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -41,7 +41,7 @@ describe('Shortcuts', () => { ).toHaveBeenCalled(); }); - it('focues preview button inside edit comment form', () => { + it('focuses preview button inside edit comment form', () => { document.querySelector('.js-note-edit').click(); Shortcuts.toggleMarkdownPreview( diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/assignees_realtime_spec.js index 2249a1c08b8..ae8f07bf901 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/assignees_realtime_spec.js @@ -2,11 +2,16 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import Mock, { issuableQueryResponse, subscriptionNullResponse } from './mock_data'; +import Mock, { + issuableQueryResponse, + subscriptionNullResponse, + subscriptionResponse, +} from './mock_data'; Vue.use(VueApollo); @@ -20,7 +25,6 @@ describe('Assignees Realtime', () => { const createComponent = ({ issuableType = 'issue', - issuableId = 1, subscriptionHandler = subscriptionInitialHandler, } = {}) => { fakeApollo = createMockApollo([ @@ -30,7 +34,6 @@ describe('Assignees Realtime', () => { wrapper = shallowMount(AssigneesRealtime, { propsData: { issuableType, - issuableId, queryVariables: { issuableIid: '1', projectPath: 'path/to/project', @@ -60,11 +63,23 @@ describe('Assignees Realtime', () => { }); }); - it('calls the subscription with correct variable for issue', () => { + it('calls the subscription with correct variable for issue', async () => { createComponent(); + await waitForPromises(); expect(subscriptionInitialHandler).toHaveBeenCalledWith({ issuableId: 'gid://gitlab/Issue/1', }); }); + + it('emits an `assigneesUpdated` event on subscription response', async () => { + createComponent({ + subscriptionHandler: jest.fn().mockResolvedValue(subscriptionResponse), + }); + await waitForPromises(); + + expect(wrapper.emitted('assigneesUpdated')).toEqual([ + [{ id: '1', assignees: subscriptionResponse.data.issuableAssigneesUpdated.assignees.nodes }], + ]); + }); }); diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js index 7a736624fc0..8d8c10d10f1 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js @@ -1,4 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue'; import { @@ -25,6 +26,11 @@ describe('EscalationStatus', () => { const findDropdownComponent = () => wrapper.findComponent(GlDropdown); const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findDropdownMenu = () => findDropdownComponent().find('.dropdown-menu'); + const toggleDropdown = async () => { + await findDropdownComponent().findComponent('button').trigger('click'); + await waitForPromises(); + }; describe('status', () => { it('shows the current status', () => { @@ -49,4 +55,32 @@ describe('EscalationStatus', () => { expect(wrapper.emitted().input[0][0]).toBe(STATUS_ACKNOWLEDGED); }); }); + + describe('close behavior', () => { + it('allows the dropdown to be closed by default', async () => { + createComponent(); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(false); + }); + + it('preventDropdownClose prevents the dropdown from closing', async () => { + createComponent({ preventDropdownClose: true }); + // Open dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + + // Attempt to close dropdown + await toggleDropdown(); + + expect(findDropdownMenu().classes('show')).toBe(true); + }); + }); }); diff --git a/spec/frontend/sidebar/mock_data.js b/spec/frontend/sidebar/mock_data.js index fbca00636b6..2b421037339 100644 --- a/spec/frontend/sidebar/mock_data.js +++ b/spec/frontend/sidebar/mock_data.js @@ -415,6 +415,28 @@ export const subscriptionNullResponse = { }, }; +export const subscriptionResponse = { + data: { + issuableAssigneesUpdated: { + id: '1', + assignees: { + nodes: [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: '/root', + status: null, + }, + ], + }, + }, + }, +}; + const mockUser1 = { __typename: 'UserCore', id: 'gid://gitlab/User/1', diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index 356628849d9..2517b625225 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -17,8 +17,7 @@ const PARTICIPANT_LIST = [PARTICIPANT, { ...PARTICIPANT, id: 2 }, { ...PARTICIPA describe('Participants', () => { let wrapper; - const getMoreParticipantsButton = () => wrapper.find('button'); - + const getMoreParticipantsButton = () => wrapper.find('[data-testid="more-participants"]'); const getCollapsedParticipantsCount = () => wrapper.find('[data-testid="collapsed-count"]'); const mountComponent = (propsData) => @@ -167,7 +166,7 @@ describe('Participants', () => { expect(wrapper.vm.isShowingMoreParticipants).toBe(false); - getMoreParticipantsButton().trigger('click'); + getMoreParticipantsButton().vm.$emit('click'); expect(wrapper.vm.isShowingMoreParticipants).toBe(true); }); diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 61424fa1eb2..9cfe136129a 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -19,8 +19,8 @@ import { SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; -import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; -import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; +import CreateSnippetMutation from '~/snippets/mutations/create_snippet.mutation.graphql'; +import UpdateSnippetMutation from '~/snippets/mutations/update_snippet.mutation.graphql'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import { testEntries, createGQLSnippetsQueryResponse, createGQLSnippet } from '../test_utils'; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 1b9d170556b..b750225a383 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -8,7 +8,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import SnippetHeader, { i18n } from '~/snippets/components/snippet_header.vue'; -import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; +import DeleteSnippetMutation from '~/snippets/mutations/delete_snippet.mutation.graphql'; import axios from '~/lib/utils/axios_utils'; import createFlash, { FLASH_TYPES } from '~/flash'; diff --git a/spec/frontend/task_list_spec.js b/spec/frontend/task_list_spec.js index bf470e7e126..fbdb73ae6de 100644 --- a/spec/frontend/task_list_spec.js +++ b/spec/frontend/task_list_spec.js @@ -121,7 +121,7 @@ describe('TaskList', () => { }); describe('update', () => { - it('should disable task list items and make a patch request then enable them again', (done) => { + it('should disable task list items and make a patch request then enable them again', () => { const response = { data: { lock_version: 3 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'disableTaskListItems').mockImplementation(() => {}); @@ -156,20 +156,17 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); - expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); - expect(taskList.lockVersion).toEqual(response.data.lock_version); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.disableTaskListItems).toHaveBeenCalledWith(event); + expect(axios.patch).toHaveBeenCalledWith(endpoint, patchData); + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onSuccess).toHaveBeenCalledWith(response.data); + expect(taskList.lockVersion).toEqual(response.data.lock_version); + }); }); }); - it('should handle request error and enable task list items', (done) => { + it('should handle request error and enable task list items', () => { const response = { data: { error: 1 } }; jest.spyOn(taskList, 'enableTaskListItems').mockImplementation(() => {}); jest.spyOn(taskList, 'onUpdate').mockImplementation(() => {}); @@ -182,12 +179,9 @@ describe('TaskList', () => { expect(taskList.onUpdate).toHaveBeenCalled(); - update - .then(() => { - expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); - expect(taskList.onError).toHaveBeenCalledWith(response.data); - }) - .then(done) - .catch(done.fail); + return update.then(() => { + expect(taskList.enableTaskListItems).toHaveBeenCalledWith(event); + expect(taskList.onError).toHaveBeenCalledWith(response.data); + }); }); }); diff --git a/spec/frontend/terraform/components/empty_state_spec.js b/spec/frontend/terraform/components/empty_state_spec.js index b1303cf2b5e..21bfff5f1be 100644 --- a/spec/frontend/terraform/components/empty_state_spec.js +++ b/spec/frontend/terraform/components/empty_state_spec.js @@ -13,15 +13,20 @@ describe('EmptyStateComponent', () => { const findLink = () => wrapper.findComponent(GlLink); beforeEach(() => { - wrapper = shallowMount(EmptyState, { propsData, stubs: { GlEmptyState, GlLink } }); + wrapper = shallowMount(EmptyState, { propsData }); + }); + + afterEach(() => { + wrapper.destroy(); }); it('should render content', () => { - expect(findEmptyState().exists()).toBe(true); - expect(wrapper.text()).toContain('Get started with Terraform'); + expect(findEmptyState().props('title')).toBe( + "Your project doesn't have any Terraform state files", + ); }); - it('should have a link to the GitLab managed Terraform States docs', () => { + it('should have a link to the GitLab managed Terraform states docs', () => { expect(findLink().attributes('href')).toBe(docsUrl); }); }); diff --git a/spec/frontend/terraform/components/mock_data.js b/spec/frontend/terraform/components/mock_data.js new file mode 100644 index 00000000000..f0109047d4c --- /dev/null +++ b/spec/frontend/terraform/components/mock_data.js @@ -0,0 +1,35 @@ +export const getStatesResponse = { + data: { + project: { + id: 'project-1', + terraformStates: { + count: 1, + nodes: { + _showDetails: true, + errorMessages: [], + loadingLock: false, + loadingRemove: false, + id: 'state-1', + name: 'state', + lockedAt: '01-01-2022', + updatedAt: '01-01-2022', + lockedByUser: { + id: 'user-1', + avatarUrl: 'avatar', + name: 'User 1', + username: 'user-1', + webUrl: 'web', + }, + latestVersion: null, + }, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'prev', + endCursor: 'next', + }, + }, + }, + }, +}; diff --git a/spec/frontend/terraform/components/states_table_actions_spec.js b/spec/frontend/terraform/components/states_table_actions_spec.js index a6c80b95af4..d01f6af9023 100644 --- a/spec/frontend/terraform/components/states_table_actions_spec.js +++ b/spec/frontend/terraform/components/states_table_actions_spec.js @@ -9,6 +9,8 @@ import StateActions from '~/terraform/components/states_table_actions.vue'; import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql'; import removeStateMutation from '~/terraform/graphql/mutations/remove_state.mutation.graphql'; import unlockStateMutation from '~/terraform/graphql/mutations/unlock_state.mutation.graphql'; +import getStatesQuery from '~/terraform/graphql/queries/get_states.query.graphql'; +import { getStatesResponse } from './mock_data'; Vue.use(VueApollo); @@ -49,6 +51,7 @@ describe('StatesTableActions', () => { [lockStateMutation, lockResponse], [removeStateMutation, removeResponse], [unlockStateMutation, unlockResponse], + [getStatesQuery, jest.fn().mockResolvedValue(getStatesResponse)], ], { Mutation: { diff --git a/spec/frontend/tracking/tracking_spec.js b/spec/frontend/tracking/tracking_spec.js index d85299cdfc3..665bf44fc77 100644 --- a/spec/frontend/tracking/tracking_spec.js +++ b/spec/frontend/tracking/tracking_spec.js @@ -129,6 +129,72 @@ describe('Tracking', () => { }); }); + describe('.definition', () => { + const TEST_VALID_BASENAME = '202108302307_default_click_button'; + const TEST_EVENT_DATA = { category: undefined, action: 'click_button' }; + let eventSpy; + let dispatcherSpy; + + beforeAll(() => { + Tracking.definitionsManifest = { + '202108302307_default_click_button': 'config/events/202108302307_default_click_button.yml', + }; + }); + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + dispatcherSpy = jest.spyOn(Tracking, 'dispatchFromDefinition'); + }); + + it('throws an error if the definition does not exists', () => { + const basename = '20220230_default_missing_definition'; + const expectedError = new Error(`Missing Snowplow event definition "${basename}"`); + + expect(() => Tracking.definition(basename)).toThrow(expectedError); + }); + + it('dispatches an event from a definition present in the manifest', () => { + Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatcherSpy).toHaveBeenCalledWith(TEST_VALID_BASENAME, {}); + }); + + it('push events to the queue if not loaded', () => { + Tracking.definitionsLoaded = false; + Tracking.definitionsEventsQueue = []; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).toBe(false); + expect(Tracking.definitionsEventsQueue[0]).toStrictEqual([TEST_VALID_BASENAME, {}]); + expect(eventSpy).not.toHaveBeenCalled(); + }); + + it('dispatch events when the definition is loaded', () => { + const definition = { key: TEST_VALID_BASENAME, ...TEST_EVENT_DATA }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsEventsQueue = []; + Tracking.definitionsLoaded = true; + + const dispatched = Tracking.definition(TEST_VALID_BASENAME); + + expect(dispatched).not.toBe(false); + expect(Tracking.definitionsEventsQueue).toEqual([]); + expect(eventSpy).toHaveBeenCalledWith(definition.category, definition.action, {}); + }); + + it('lets defined event data takes precedence', () => { + const definition = { key: TEST_VALID_BASENAME, category: undefined, action: 'click_button' }; + const eventData = { category: TEST_CATEGORY }; + Tracking.definitions = [{ ...definition }]; + Tracking.definitionsLoaded = true; + + Tracking.definition(TEST_VALID_BASENAME, eventData); + + expect(eventSpy).toHaveBeenCalledWith(TEST_CATEGORY, definition.action, eventData); + }); + }); + describe('.enableFormTracking', () => { it('tells snowplow to enable form tracking, with only explicit contexts', () => { const config = { forms: { allow: ['form-class1'] }, fields: { allow: ['input-class1'] } }; diff --git a/spec/frontend/user_lists/components/edit_user_list_spec.js b/spec/frontend/user_lists/components/edit_user_list_spec.js index 7cafe5e1f56..941c8244247 100644 --- a/spec/frontend/user_lists/components/edit_user_list_spec.js +++ b/spec/frontend/user_lists/components/edit_user_list_spec.js @@ -8,7 +8,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import EditUserList from '~/user_lists/components/edit_user_list.vue'; import UserListForm from '~/user_lists/components/user_list_form.vue'; import createStore from '~/user_lists/store/edit'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/new_user_list_spec.js b/spec/frontend/user_lists/components/new_user_list_spec.js index 5eb44970fe4..ace4a284347 100644 --- a/spec/frontend/user_lists/components/new_user_list_spec.js +++ b/spec/frontend/user_lists/components/new_user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import { redirectTo } from '~/lib/utils/url_utility'; import NewUserList from '~/user_lists/components/new_user_list.vue'; import createStore from '~/user_lists/store/new'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/components/user_list_form_spec.js b/spec/frontend/user_lists/components/user_list_form_spec.js index 42f7659600e..e09d8eac32f 100644 --- a/spec/frontend/user_lists/components/user_list_form_spec.js +++ b/spec/frontend/user_lists/components/user_list_form_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import Form from '~/user_lists/components/user_list_form.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('user_lists/components/user_list_form', () => { let wrapper; diff --git a/spec/frontend/user_lists/components/user_list_spec.js b/spec/frontend/user_lists/components/user_list_spec.js index 88dad06938b..f126c733dd5 100644 --- a/spec/frontend/user_lists/components/user_list_spec.js +++ b/spec/frontend/user_lists/components/user_list_spec.js @@ -7,7 +7,7 @@ import Api from '~/api'; import UserList from '~/user_lists/components/user_list.vue'; import createStore from '~/user_lists/store/show'; import { parseUserIds, stringifyUserIds } from '~/user_lists/store/utils'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_spec.js b/spec/frontend/user_lists/components/user_lists_spec.js index 10742c029c1..161eb036361 100644 --- a/spec/frontend/user_lists/components/user_lists_spec.js +++ b/spec/frontend/user_lists/components/user_lists_spec.js @@ -9,7 +9,7 @@ import UserListsComponent from '~/user_lists/components/user_lists.vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; import createStore from '~/user_lists/store/index'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/user_lists/components/user_lists_table_spec.js b/spec/frontend/user_lists/components/user_lists_table_spec.js index 63587703392..08eb8ae0843 100644 --- a/spec/frontend/user_lists/components/user_lists_table_spec.js +++ b/spec/frontend/user_lists/components/user_lists_table_spec.js @@ -3,7 +3,7 @@ import { mount } from '@vue/test-utils'; import * as timeago from 'timeago.js'; import { nextTick } from 'vue'; import UserListsTable from '~/user_lists/components/user_lists_table.vue'; -import { userList } from '../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('timeago.js', () => ({ format: jest.fn().mockReturnValue('2 weeks ago'), diff --git a/spec/frontend/user_lists/store/edit/actions_spec.js b/spec/frontend/user_lists/store/edit/actions_spec.js index c4b0f888d3e..ca56c935ea5 100644 --- a/spec/frontend/user_lists/store/edit/actions_spec.js +++ b/spec/frontend/user_lists/store/edit/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/edit/actions'; import * as types from '~/user_lists/store/edit/mutation_types'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/user_lists/store/edit/mutations_spec.js b/spec/frontend/user_lists/store/edit/mutations_spec.js index 0943c64e934..7971906429b 100644 --- a/spec/frontend/user_lists/store/edit/mutations_spec.js +++ b/spec/frontend/user_lists/store/edit/mutations_spec.js @@ -2,7 +2,7 @@ import statuses from '~/user_lists/constants/edit'; import * as types from '~/user_lists/store/edit/mutation_types'; import mutations from '~/user_lists/store/edit/mutations'; import createState from '~/user_lists/store/edit/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('User List Edit Mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/index/actions_spec.js b/spec/frontend/user_lists/store/index/actions_spec.js index c5d7d557de9..4a8d0afb963 100644 --- a/spec/frontend/user_lists/store/index/actions_spec.js +++ b/spec/frontend/user_lists/store/index/actions_spec.js @@ -12,7 +12,7 @@ import { } from '~/user_lists/store/index/actions'; import * as types from '~/user_lists/store/index/mutation_types'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api.js'); @@ -24,14 +24,13 @@ describe('~/user_lists/store/index/actions', () => { }); describe('setUserListsOptions', () => { - it('should commit SET_USER_LISTS_OPTIONS mutation', (done) => { - testAction( + it('should commit SET_USER_LISTS_OPTIONS mutation', () => { + return testAction( setUserListsOptions, { page: '1', scope: 'all' }, state, [{ type: types.SET_USER_LISTS_OPTIONS, payload: { page: '1', scope: 'all' } }], [], - done, ); }); }); @@ -42,8 +41,8 @@ describe('~/user_lists/store/index/actions', () => { }); describe('success', () => { - it('dispatches requestUserLists and receiveUserListsSuccess ', (done) => { - testAction( + it('dispatches requestUserLists and receiveUserListsSuccess ', () => { + return testAction( fetchUserLists, null, state, @@ -57,16 +56,15 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsSuccess', }, ], - done, ); }); }); describe('error', () => { - it('dispatches requestUserLists and receiveUserListsError ', (done) => { + it('dispatches requestUserLists and receiveUserListsError ', () => { Api.fetchFeatureFlagUserLists.mockRejectedValue(); - testAction( + return testAction( fetchUserLists, null, state, @@ -79,21 +77,20 @@ describe('~/user_lists/store/index/actions', () => { type: 'receiveUserListsError', }, ], - done, ); }); }); }); describe('requestUserLists', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], [], done); + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction(requestUserLists, null, state, [{ type: types.REQUEST_USER_LISTS }], []); }); }); describe('receiveUserListsSuccess', () => { - it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_SUCCESS mutation', () => { + return testAction( receiveUserListsSuccess, { data: [userList], headers: {} }, state, @@ -104,20 +101,18 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('receiveUserListsError', () => { - it('should commit RECEIVE_USER_LISTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_USER_LISTS_ERROR mutation', () => { + return testAction( receiveUserListsError, null, state, [{ type: types.RECEIVE_USER_LISTS_ERROR }], [], - done, ); }); }); @@ -132,14 +127,13 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockResolvedValue(); }); - it('should refresh the user lists', (done) => { - testAction( + it('should refresh the user lists', () => { + return testAction( deleteUserList, userList, state, [], [{ type: 'requestDeleteUserList', payload: userList }, { type: 'fetchUserLists' }], - done, ); }); }); @@ -149,8 +143,8 @@ describe('~/user_lists/store/index/actions', () => { Api.deleteFeatureFlagUserList.mockRejectedValue({ response: { data: 'some error' } }); }); - it('should dispatch receiveDeleteUserListError', (done) => { - testAction( + it('should dispatch receiveDeleteUserListError', () => { + return testAction( deleteUserList, userList, state, @@ -162,15 +156,14 @@ describe('~/user_lists/store/index/actions', () => { payload: { list: userList, error: 'some error' }, }, ], - done, ); }); }); }); describe('receiveDeleteUserListError', () => { - it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', (done) => { - testAction( + it('should commit RECEIVE_DELETE_USER_LIST_ERROR with the given list', () => { + return testAction( receiveDeleteUserListError, { list: userList, error: 'mock error' }, state, @@ -181,22 +174,20 @@ describe('~/user_lists/store/index/actions', () => { }, ], [], - done, ); }); }); describe('clearAlert', () => { - it('should commit RECEIVE_CLEAR_ALERT', (done) => { + it('should commit RECEIVE_CLEAR_ALERT', () => { const alertIndex = 3; - testAction( + return testAction( clearAlert, alertIndex, state, [{ type: 'RECEIVE_CLEAR_ALERT', payload: alertIndex }], [], - done, ); }); }); diff --git a/spec/frontend/user_lists/store/index/mutations_spec.js b/spec/frontend/user_lists/store/index/mutations_spec.js index 370838ae5fb..18d6a9b8f38 100644 --- a/spec/frontend/user_lists/store/index/mutations_spec.js +++ b/spec/frontend/user_lists/store/index/mutations_spec.js @@ -2,7 +2,7 @@ import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import * as types from '~/user_lists/store/index/mutation_types'; import mutations from '~/user_lists/store/index/mutations'; import createState from '~/user_lists/store/index/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; describe('~/user_lists/store/index/mutations', () => { let state; diff --git a/spec/frontend/user_lists/store/new/actions_spec.js b/spec/frontend/user_lists/store/new/actions_spec.js index 916ec2e6da7..fa69fa7fa66 100644 --- a/spec/frontend/user_lists/store/new/actions_spec.js +++ b/spec/frontend/user_lists/store/new/actions_spec.js @@ -4,7 +4,7 @@ import { redirectTo } from '~/lib/utils/url_utility'; import * as actions from '~/user_lists/store/new/actions'; import * as types from '~/user_lists/store/new/mutation_types'; import createState from '~/user_lists/store/new/state'; -import { userList } from '../../../feature_flags/mock_data'; +import { userList } from 'jest/feature_flags/mock_data'; jest.mock('~/api'); jest.mock('~/lib/utils/url_utility'); diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index 36850e623c7..4985417ad99 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createFlash from '~/flash'; @@ -28,11 +29,6 @@ const testApprovals = () => ({ }); const testApprovalRulesResponse = () => ({ rules: [{ id: 2 }] }); -// For some reason, the `Promise.resolve()` needs to be deferred -// or the timing doesn't work. -const tick = () => Promise.resolve(); -const waitForTick = (done) => tick().then(done).catch(done.fail); - describe('MRWidget approvals', () => { let wrapper; let service; @@ -105,7 +101,7 @@ describe('MRWidget approvals', () => { // eslint-disable-next-line no-restricted-syntax wrapper.setData({ fetchingApprovals: true }); - return tick().then(() => { + return nextTick().then(() => { expect(wrapper.text()).toContain(FETCH_LOADING); }); }); @@ -116,10 +112,10 @@ describe('MRWidget approvals', () => { }); describe('when fetch approvals error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'fetchApprovals').mockReturnValue(Promise.reject()); createComponent(); - waitForTick(done); + return nextTick(); }); it('still shows loading message', () => { @@ -133,13 +129,13 @@ describe('MRWidget approvals', () => { describe('action button', () => { describe('when mr is closed', () => { - beforeEach((done) => { + beforeEach(() => { mr.isOpen = false; mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -148,12 +144,12 @@ describe('MRWidget approvals', () => { }); describe('when user cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = false; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('action is not rendered', () => { @@ -168,9 +164,9 @@ describe('MRWidget approvals', () => { }); describe('and MR is unapproved', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action is rendered', () => { @@ -188,10 +184,10 @@ describe('MRWidget approvals', () => { }); describe('with no approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = []; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve action (with inverted style) is rendered', () => { @@ -204,10 +200,10 @@ describe('MRWidget approvals', () => { }); describe('with approvers', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.approved_by = [{ user: { id: 7 } }]; createComponent(); - waitForTick(done); + return nextTick(); }); it('approve additionally action is rendered', () => { @@ -221,9 +217,9 @@ describe('MRWidget approvals', () => { }); describe('when approve action is clicked', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('shows loading icon', () => { @@ -234,15 +230,15 @@ describe('MRWidget approvals', () => { action.vm.$emit('click'); - return tick().then(() => { + return nextTick().then(() => { expect(action.props('loading')).toBe(true); }); }); describe('and after loading', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service approve', () => { @@ -259,10 +255,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'approveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -273,12 +269,12 @@ describe('MRWidget approvals', () => { }); describe('when user has approved', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_has_approved = true; mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('revoke action is rendered', () => { @@ -291,9 +287,9 @@ describe('MRWidget approvals', () => { describe('when revoke action is clicked', () => { describe('and successful', () => { - beforeEach((done) => { + beforeEach(() => { findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('calls service unapprove', () => { @@ -310,10 +306,10 @@ describe('MRWidget approvals', () => { }); describe('and error', () => { - beforeEach((done) => { + beforeEach(() => { jest.spyOn(service, 'unapproveMergeRequest').mockReturnValue(Promise.reject()); findAction().vm.$emit('click'); - waitForTick(done); + return nextTick(); }); it('flashes error message', () => { @@ -333,11 +329,11 @@ describe('MRWidget approvals', () => { }); describe('and can approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = true; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -350,11 +346,11 @@ describe('MRWidget approvals', () => { }); describe('and cannot approve', () => { - beforeEach((done) => { + beforeEach(() => { mr.approvals.user_can_approve = false; createComponent(); - waitForTick(done); + return nextTick(); }); it('is shown', () => { @@ -369,9 +365,9 @@ describe('MRWidget approvals', () => { }); describe('approvals summary', () => { - beforeEach((done) => { + beforeEach(() => { createComponent(); - waitForTick(done); + return nextTick(); }); it('is rendered with props', () => { diff --git a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js index 64e802c4fa5..98cfc04eb25 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/utils_spec.js @@ -8,7 +8,7 @@ describe('generateText', () => { ${'%{danger_start}Hello world%{danger_end}'} | ${'Hello world'} ${'%{critical_start}Hello world%{critical_end}'} | ${'Hello world'} ${'%{same_start}Hello world%{same_end}'} | ${'Hello world'} - ${'%{small_start}Hello world%{small_end}'} | ${'Hello world'} + ${'%{small_start}Hello world%{small_end}'} | ${'Hello world'} ${'%{strong_start}%{danger_start}Hello world%{danger_end}%{strong_end}'} | ${'Hello world'} ${'%{no_exist_start}Hello world%{no_exist_end}'} | ${'Hello world'} ${['array']} | ${null} diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js index c0a30a5093d..f0106914674 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -175,22 +175,19 @@ describe('MemoryUsage', () => { expect(el.querySelector('.js-usage-info')).toBeDefined(); }); - it('should show loading metrics message while metrics are being loaded', (done) => { + it('should show loading metrics message while metrics are being loaded', async () => { vm.loadingMetrics = true; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); - - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined(); + expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics); }); - it('should show deployment memory usage when metrics are loaded', (done) => { + it('should show deployment memory usage when metrics are loaded', async () => { // ignore BoostrapVue warnings jest.spyOn(console, 'warn').mockImplementation(); @@ -199,37 +196,32 @@ describe('MemoryUsage', () => { vm.loadFailed = false; vm.memoryMetrics = metricsMockData.metrics.memory_values[0].values; - nextTick(() => { - expect(el.querySelector('.memory-graph-container')).toBeDefined(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); - done(); - }); + await nextTick(); + + expect(el.querySelector('.memory-graph-container')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics); }); - it('should show failure message when metrics loading failed', (done) => { + it('should show failure message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = true; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed); }); - it('should show metrics unavailable message when metrics loading failed', (done) => { + it('should show metrics unavailable message when metrics loading failed', async () => { vm.loadingMetrics = false; vm.hasMetrics = false; vm.loadFailed = false; - nextTick(() => { - expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + await nextTick(); - expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); - done(); - }); + expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined(); + expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js index 7d86e453bc7..8efc4d84624 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_merged_spec.js @@ -198,14 +198,13 @@ describe('MRWidgetMerged', () => { ); }); - it('hides button to copy commit SHA if SHA does not exist', (done) => { + it('hides button to copy commit SHA if SHA does not exist', async () => { vm.mr.mergeCommitSha = null; - nextTick(() => { - expect(selectors.copyMergeShaButton).toBe(null); - expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); - done(); - }); + await nextTick(); + + expect(selectors.copyMergeShaButton).toBe(null); + expect(vm.$el.querySelector('.mr-info-list').innerText).not.toContain('with'); }); it('shows merge commit SHA link', () => { @@ -214,24 +213,22 @@ describe('MRWidgetMerged', () => { expect(selectors.mergeCommitShaLink.href).toBe(vm.mr.mergeCommitPath); }); - it('should not show source branch deleted text', (done) => { + it('should not show source branch deleted text', async () => { vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); - it('should show source branch deleting text', (done) => { + it('should show source branch deleting text', async () => { vm.mr.isRemovingSourceBranch = true; vm.mr.sourceBranchRemoved = false; - nextTick(() => { - expect(vm.$el.innerText).toContain('The source branch is being deleted'); - expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); - done(); - }); + await nextTick(); + + expect(vm.$el.innerText).toContain('The source branch is being deleted'); + expect(vm.$el.innerText).not.toContain('The source branch has been deleted'); }); it('should use mergedEvent mergedAt as tooltip title', () => { diff --git a/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js new file mode 100644 index 00000000000..88b8e32bd5d --- /dev/null +++ b/spec/frontend/vue_mr_widget/extensions/test_report/index_spec.js @@ -0,0 +1,149 @@ +import { GlButton } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import testReportExtension from '~/vue_merge_request_widget/extensions/test_report'; +import { i18n } from '~/vue_merge_request_widget/extensions/test_report/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { trimText } from 'helpers/text_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; +import httpStatusCodes from '~/lib/utils/http_status'; + +import { failedReport } from 'jest/reports/mock_data/mock_data'; +import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; +import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'; +import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; +import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; +import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; + +const reportWithParsingErrors = failedReport; +reportWithParsingErrors.suites[0].suite_errors = { + head: 'JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + base: 'JUnit data parsing failed: string not matched', +}; + +describe('Test report extension', () => { + let wrapper; + let mock; + + registerExtension(testReportExtension); + + const endpoint = '/root/repo/-/merge_requests/4/test_reports.json'; + + const mockApi = (statusCode, data = mixedResultsTestReports) => { + mock.onGet(endpoint).reply(statusCode, data); + }; + + const findToggleCollapsedButton = () => wrapper.findByTestId('toggle-button'); + const findTertiaryButton = () => wrapper.find(GlButton); + const findAllExtensionListItems = () => wrapper.findAllByTestId('extension-list-item'); + + const createComponent = () => { + wrapper = mountExtended(extensionsContainer, { + propsData: { + mr: { + testResultsPath: endpoint, + headBlobPath: 'head/blob/path', + pipeline: { path: 'pipeline/path' }, + }, + }, + }); + }; + + const createExpandedWidgetWithData = async (data = mixedResultsTestReports) => { + mockApi(httpStatusCodes.OK, data); + createComponent(); + await waitForPromises(); + findToggleCollapsedButton().trigger('click'); + await waitForPromises(); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + describe('summary', () => { + it('displays loading text', () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + expect(wrapper.text()).toContain(i18n.loading); + }); + + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + + it.each` + description | mockData | expectedResult + ${'mixed test results'} | ${mixedResultsTestReports} | ${'Test summary: 2 failed and 2 fixed test results, 11 total tests'} + ${'unchanged test results'} | ${successTestReports} | ${'Test summary: no changed test results, 11 total tests'} + ${'tests with errors'} | ${newErrorsTestReports} | ${'Test summary: 2 errors, 11 total tests'} + ${'failed test results'} | ${newFailedTestReports} | ${'Test summary: 2 failed, 11 total tests'} + ${'resolved failures'} | ${resolvedFailures} | ${'Test summary: 4 fixed test results, 11 total tests'} + `('displays summary text for $description', async ({ mockData, expectedResult }) => { + mockApi(httpStatusCodes.OK, mockData); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(expectedResult); + }); + + it('displays a link to the full report', async () => { + mockApi(httpStatusCodes.OK); + createComponent(); + + await waitForPromises(); + + expect(findTertiaryButton().text()).toBe('Full report'); + expect(findTertiaryButton().attributes('href')).toBe('pipeline/path/test_report'); + }); + + it('shows an error when a suite has a parsing error', async () => { + mockApi(httpStatusCodes.OK, reportWithParsingErrors); + createComponent(); + + await waitForPromises(); + + expect(wrapper.text()).toContain(i18n.error); + }); + }); + + describe('expanded data', () => { + it('displays summary for each suite', async () => { + await createExpandedWidgetWithData(); + + expect(trimText(findAllExtensionListItems().at(0).text())).toBe( + 'rspec:pg: 1 failed and 2 fixed test results, 8 total tests', + ); + expect(trimText(findAllExtensionListItems().at(1).text())).toBe( + 'java ant: 1 failed, 3 total tests', + ); + }); + + it('displays suite parsing errors', async () => { + await createExpandedWidgetWithData(reportWithParsingErrors); + + const suiteText = trimText(findAllExtensionListItems().at(0).text()); + + expect(suiteText).toContain( + 'Head report parsing error: JUnit XML parsing failed: 2:24: FATAL: attributes construct error', + ); + expect(suiteText).toContain( + 'Base report parsing error: JUnit data parsing failed: string not matched', + ); + }); + }); +}); 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 0540107ea5f..9719e81fe12 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -46,6 +46,8 @@ describe('MrWidgetOptions', () => { let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findExtensionToggleButton = () => + wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); beforeEach(() => { gl.mrWidgetData = { ...mockData }; @@ -187,9 +189,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should render collaboration status', () => { @@ -198,9 +200,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is not opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = false; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -215,9 +217,9 @@ describe('MrWidgetOptions', () => { }); describe('when merge request is opened', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.isOpen = true; - nextTick(done); + return nextTick(); }); it('should not render collaboration status', () => { @@ -229,11 +231,11 @@ describe('MrWidgetOptions', () => { describe('showMergePipelineForkWarning', () => { describe('when the source project and target project are the same', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 1); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -242,11 +244,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are not enabled', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', false); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be false', () => { @@ -255,11 +257,11 @@ describe('MrWidgetOptions', () => { }); describe('when merge pipelines are enabled _and_ the source project and target project are different', () => { - beforeEach((done) => { + beforeEach(() => { Vue.set(wrapper.vm.mr, 'mergePipelinesEnabled', true); Vue.set(wrapper.vm.mr, 'sourceProjectId', 1); Vue.set(wrapper.vm.mr, 'targetProjectId', 2); - nextTick(done); + return nextTick(); }); it('should be true', () => { @@ -439,15 +441,10 @@ describe('MrWidgetOptions', () => { expect(setFaviconOverlay).toHaveBeenCalledWith(overlayDataUrl); }); - it('should not call setFavicon when there is no ciStatusFaviconPath', (done) => { + it('should not call setFavicon when there is no ciStatusFaviconPath', async () => { wrapper.vm.mr.ciStatusFaviconPath = null; - wrapper.vm - .setFaviconHelper() - .then(() => { - expect(faviconElement.getAttribute('href')).toEqual(null); - done(); - }) - .catch(done.fail); + await wrapper.vm.setFaviconHelper(); + expect(faviconElement.getAttribute('href')).toEqual(null); }); }); @@ -534,44 +531,36 @@ describe('MrWidgetOptions', () => { expect(wrapper.find('.close-related-link').exists()).toBe(true); }); - it('does not render if state is nothingToMerge', (done) => { + it('does not render if state is nothingToMerge', async () => { wrapper.vm.mr.state = stateKey.nothingToMerge; - nextTick(() => { - expect(wrapper.find('.close-related-link').exists()).toBe(false); - done(); - }); + await nextTick(); + expect(wrapper.find('.close-related-link').exists()).toBe(false); }); }); describe('rendering source branch removal status', () => { - it('renders when user cannot remove branch and branch should be removed', (done) => { + it('renders when user cannot remove branch and branch should be removed', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'readyToMerge'; - nextTick(() => { - const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - - expect(wrapper.text()).toContain('Deletes the source branch'); - expect(tooltip.attributes('title')).toBe( - 'A user with write access to the source branch selected this option', - ); + await nextTick(); + const tooltip = wrapper.find('[data-testid="question-o-icon"]'); - done(); - }); + expect(wrapper.text()).toContain('Deletes the source branch'); + expect(tooltip.attributes('title')).toBe( + 'A user with write access to the source branch selected this option', + ); }); - it('does not render in merged state', (done) => { + it('does not render in merged state', async () => { wrapper.vm.mr.canRemoveSourceBranch = false; wrapper.vm.mr.shouldRemoveSourceBranch = true; wrapper.vm.mr.state = 'merged'; - nextTick(() => { - expect(wrapper.text()).toContain('The source branch has been deleted'); - expect(wrapper.text()).not.toContain('Deletes the source branch'); - - done(); - }); + await nextTick(); + expect(wrapper.text()).toContain('The source branch has been deleted'); + expect(wrapper.text()).not.toContain('Deletes the source branch'); }); }); @@ -605,7 +594,7 @@ describe('MrWidgetOptions', () => { status: SUCCESS, }; - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.deployments.push( { ...deploymentMockData, @@ -616,7 +605,7 @@ describe('MrWidgetOptions', () => { }, ); - nextTick(done); + return nextTick(); }); it('renders multiple deployments', () => { @@ -639,7 +628,7 @@ describe('MrWidgetOptions', () => { describe('pipeline for target branch after merge', () => { describe('with information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; wrapper.vm.mr.mergePipeline = { id: 127, @@ -747,7 +736,7 @@ describe('MrWidgetOptions', () => { }, cancel_path: '/root/ci-web-terminal/pipelines/127/cancel', }; - nextTick(done); + return nextTick(); }); it('renders pipeline block', () => { @@ -755,7 +744,7 @@ describe('MrWidgetOptions', () => { }); describe('with post merge deployments', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.postMergeDeployments = [ { id: 15, @@ -787,7 +776,7 @@ describe('MrWidgetOptions', () => { }, ]; - nextTick(done); + return nextTick(); }); it('renders post deployment information', () => { @@ -797,10 +786,10 @@ describe('MrWidgetOptions', () => { }); describe('without information for target branch pipeline', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'merged'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -809,10 +798,10 @@ describe('MrWidgetOptions', () => { }); describe('when state is not merged', () => { - beforeEach((done) => { + beforeEach(() => { wrapper.vm.mr.state = 'archived'; - nextTick(done); + return nextTick(); }); it('does not render pipeline block', () => { @@ -905,7 +894,7 @@ describe('MrWidgetOptions', () => { beforeEach(() => { pollRequest = jest.spyOn(Poll.prototype, 'makeRequest'); - registerExtension(workingExtension); + registerExtension(workingExtension()); createComponent(); }); @@ -937,9 +926,7 @@ describe('MrWidgetOptions', () => { it('renders full data', async () => { await waitForPromises(); - wrapper - .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') - .trigger('click'); + findExtensionToggleButton().trigger('click'); await nextTick(); @@ -975,6 +962,24 @@ describe('MrWidgetOptions', () => { }); }); + describe('expansion', () => { + it('hides collapse button', async () => { + registerExtension(workingExtension(false)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(false); + }); + + it('shows collapse button', async () => { + registerExtension(workingExtension(true)); + createComponent(); + await waitForPromises(); + + expect(findExtensionToggleButton().exists()).toBe(true); + }); + }); + describe('mock polling extension', () => { let pollRequest; let pollStop; @@ -1025,7 +1030,7 @@ describe('MrWidgetOptions', () => { it('captures sentry error and displays error when poll has failed', () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }); }); }); @@ -1036,7 +1041,7 @@ describe('MrWidgetOptions', () => { const itHandlesTheException = () => { expect(captureException).toHaveBeenCalledTimes(1); expect(captureException).toHaveBeenCalledWith(new Error('Fetch error')); - expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('error'); + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe('failed'); }; beforeEach(() => { diff --git a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js index 9423fa17c44..22562bb4ddb 100644 --- a/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js +++ b/spec/frontend/vue_mr_widget/stores/artifacts_list/actions_spec.js @@ -22,27 +22,25 @@ describe('Artifacts App Store Actions', () => { }); describe('setEndpoint', () => { - it('should commit SET_ENDPOINT mutation', (done) => { - testAction( + it('should commit SET_ENDPOINT mutation', () => { + return testAction( setEndpoint, 'endpoint.json', mockedState, [{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }], [], - done, ); }); }); describe('requestArtifacts', () => { - it('should commit REQUEST_ARTIFACTS mutation', (done) => { - testAction( + it('should commit REQUEST_ARTIFACTS mutation', () => { + return testAction( requestArtifacts, null, mockedState, [{ type: types.REQUEST_ARTIFACTS }], [], - done, ); }); }); @@ -62,7 +60,7 @@ describe('Artifacts App Store Actions', () => { }); describe('success', () => { - it('dispatches requestArtifacts and receiveArtifactsSuccess ', (done) => { + it('dispatches requestArtifacts and receiveArtifactsSuccess ', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, [ { text: 'result.txt', @@ -72,7 +70,7 @@ describe('Artifacts App Store Actions', () => { }, ]); - testAction( + return testAction( fetchArtifacts, null, mockedState, @@ -96,7 +94,6 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsSuccess', }, ], - done, ); }); }); @@ -106,8 +103,8 @@ describe('Artifacts App Store Actions', () => { mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); }); - it('dispatches requestArtifacts and receiveArtifactsError ', (done) => { - testAction( + it('dispatches requestArtifacts and receiveArtifactsError ', () => { + return testAction( fetchArtifacts, null, mockedState, @@ -120,45 +117,41 @@ describe('Artifacts App Store Actions', () => { type: 'receiveArtifactsError', }, ], - done, ); }); }); }); describe('receiveArtifactsSuccess', () => { - it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_SUCCESS mutation with 200', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 200 }, mockedState, [{ type: types.RECEIVE_ARTIFACTS_SUCCESS, payload: { summary: {} } }], [], - done, ); }); - it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', (done) => { - testAction( + it('should not commit RECEIVE_ARTIFACTS_SUCCESS mutation with 204', () => { + return testAction( receiveArtifactsSuccess, { data: { summary: {} }, status: 204 }, mockedState, [], [], - done, ); }); }); describe('receiveArtifactsError', () => { - it('should commit RECEIVE_ARTIFACTS_ERROR mutation', (done) => { - testAction( + it('should commit RECEIVE_ARTIFACTS_ERROR mutation', () => { + return testAction( receiveArtifactsError, null, mockedState, [{ type: types.RECEIVE_ARTIFACTS_ERROR }], [], - done, ); }); }); diff --git a/spec/frontend/vue_mr_widget/test_extensions.js b/spec/frontend/vue_mr_widget/test_extensions.js index 986c1d6545a..6344636873f 100644 --- a/spec/frontend/vue_mr_widget/test_extensions.js +++ b/spec/frontend/vue_mr_widget/test_extensions.js @@ -1,6 +1,6 @@ import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; -export const workingExtension = { +export const workingExtension = (shouldCollapse = true) => ({ name: 'WidgetTestExtension', props: ['targetProjectFullPath'], expandEvent: 'test_expand_event', @@ -11,6 +11,9 @@ export const workingExtension = { statusIcon({ count }) { return count > 0 ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; }, + shouldCollapse() { + return shouldCollapse; + }, }, methods: { fetchCollapsedData({ targetProjectFullPath }) { @@ -36,7 +39,7 @@ export const workingExtension = { ]); }, }, -}; +}); export const collapsedDataErrorExtension = { name: 'WidgetTestCollapsedErrorExtension', @@ -99,7 +102,7 @@ export const fullDataErrorExtension = { }; export const pollingExtension = { - ...workingExtension, + ...workingExtension(), enablePolling: true, }; diff --git a/spec/frontend/vue_shared/alert_details/alert_details_spec.js b/spec/frontend/vue_shared/alert_details/alert_details_spec.js index 7ee6e29e6de..7aa54a1c55a 100644 --- a/spec/frontend/vue_shared/alert_details/alert_details_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_details_spec.js @@ -12,12 +12,17 @@ import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants'; import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store/'; +import service from '~/vue_shared/alert_details/service'; import mockAlerts from './mocks/alerts.json'; const mockAlert = mockAlerts[0]; const environmentName = 'Production'; const environmentPath = '/fake/path'; +jest.mock('~/vue_shared/alert_details/service'); + describe('AlertDetails', () => { let environmentData = { name: environmentName, path: environmentPath }; let mock; @@ -67,9 +72,11 @@ describe('AlertDetails', () => { $route: { params: {} }, }, stubs: { - ...stubs, AlertSummaryRow, + 'metric-images-tab': true, + ...stubs, }, + store: createStore({}, service), }), ); } @@ -91,7 +98,7 @@ describe('AlertDetails', () => { const findEnvironmentName = () => wrapper.findByTestId('environmentName'); const findEnvironmentPath = () => wrapper.findByTestId('environmentPath'); const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable); - const findMetricsTab = () => wrapper.findByTestId('metrics'); + const findMetricsTab = () => wrapper.findComponent(MetricImagesTab); describe('Alert details', () => { describe('when alert is null', () => { @@ -129,8 +136,21 @@ describe('AlertDetails', () => { expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true); expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt); }); + }); + + describe('Metrics tab', () => { + it('should mount without errors', () => { + mountComponent({ + mountMethod: mount, + provide: { + canUpdate: true, + iid: '1', + }, + stubs: { + MetricImagesTab, + }, + }); - it('renders the metrics tab', () => { expect(findMetricsTab().exists()).toBe(true); }); }); @@ -312,7 +332,9 @@ describe('AlertDetails', () => { describe('header', () => { const findHeader = () => wrapper.findByTestId('alert-header'); - const stubs = { TimeAgoTooltip: { template: 'now' } }; + const stubs = { + TimeAgoTooltip: { template: 'now' }, + }; describe('individual header fields', () => { describe.each` diff --git a/spec/frontend/vue_shared/alert_details/service_spec.js b/spec/frontend/vue_shared/alert_details/service_spec.js new file mode 100644 index 00000000000..790854d0ca7 --- /dev/null +++ b/spec/frontend/vue_shared/alert_details/service_spec.js @@ -0,0 +1,44 @@ +import { fileList, fileListRaw } from 'jest/vue_shared/components/metric_images/mock_data'; +import { + getMetricImages, + uploadMetricImage, + updateMetricImage, + deleteMetricImage, +} from '~/vue_shared/alert_details/service'; +import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api'; + +jest.mock('~/api/alert_management_alerts_api'); + +describe('Alert details service', () => { + it('fetches metric images', async () => { + alertManagementAlertsApi.fetchAlertMetricImages.mockResolvedValue({ data: fileListRaw }); + const result = await getMetricImages(); + + expect(alertManagementAlertsApi.fetchAlertMetricImages).toHaveBeenCalled(); + expect(result).toEqual(fileList); + }); + + it('uploads a metric image', async () => { + alertManagementAlertsApi.uploadAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await uploadMetricImage(); + + expect(alertManagementAlertsApi.uploadAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('updates a metric image', async () => { + alertManagementAlertsApi.updateAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] }); + const result = await updateMetricImage(); + + expect(alertManagementAlertsApi.updateAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual(fileList[0]); + }); + + it('deletes a metric image', async () => { + alertManagementAlertsApi.deleteAlertMetricImage.mockResolvedValue({ data: '' }); + const result = await deleteMetricImage(); + + expect(alertManagementAlertsApi.deleteAlertMetricImage).toHaveBeenCalled(); + expect(result).toEqual({}); + }); +}); 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 c14cf0db370..bdf5ea23812 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 @@ -218,65 +218,88 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
      - + + + + + + + + +
      +
      `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap deleted file mode 100644 index 1d8e04b83a3..00000000000 --- a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Identicon entity id is a GraphQL id matches snapshot 1`] = ` -
      - - E - -
      -`; - -exports[`Identicon entity id is a number matches snapshot 1`] = ` -
      - - E - -
      -`; diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 95e9760c181..1c8cf726aca 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -76,7 +76,7 @@ describe('vue_shared/components/awards_list', () => { count: Number(x.find('.js-counter').text()), }; }); - const findAddAwardButton = () => wrapper.find('.js-add-award'); + const findAddAwardButton = () => wrapper.find('[data-testid="emoji-picker"]'); describe('default', () => { beforeEach(() => { @@ -151,7 +151,6 @@ describe('vue_shared/components/awards_list', () => { const btn = findAddAwardButton(); expect(btn.exists()).toBe(true); - expect(btn.classes(TEST_ADD_BUTTON_CLASS)).toBe(true); }); }); 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 663ebd3e12f..4b44311b253 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 @@ -2,9 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants'; import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue'; -import LineHighlighter from '~/blob/line_highlighter'; - -jest.mock('~/blob/line_highlighter'); describe('Blob Simple Viewer component', () => { let wrapper; @@ -30,20 +27,6 @@ describe('Blob Simple Viewer component', () => { wrapper.destroy(); }); - describe('refactorBlobViewer feature flag', () => { - it('loads the LineHighlighter if refactorBlobViewer is enabled', () => { - createComponent('', false, { refactorBlobViewer: true }); - - expect(LineHighlighter).toHaveBeenCalled(); - }); - - it('does not load the LineHighlighter if refactorBlobViewer is disabled', () => { - createComponent('', false, { refactorBlobViewer: false }); - - expect(LineHighlighter).not.toHaveBeenCalled(); - }); - }); - it('does not fail if content is empty', () => { const spy = jest.spyOn(window.console, 'error'); createComponent(''); 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 575e8a73050..b6a181e6a0b 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 @@ -26,7 +26,6 @@ import { tokenValueMilestone, tokenValueMembership, tokenValueConfidential, - tokenValueEmpty, } from './mock_data'; jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ @@ -207,33 +206,14 @@ describe('FilteredSearchBarRoot', () => { }); }); - describe('watchers', () => { - describe('filterValue', () => { - it('emits component event `onFilter` with empty array and false when filter was never selected', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueEmpty] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); - - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], false]); - }); + describe('events', () => { + it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { + wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - it('emits component event `onFilter` with empty array and true when initially selected filter value was cleared', async () => { - wrapper = createComponent({ initialFilterValue: [tokenValueLabel] }); - // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details - // eslint-disable-next-line no-restricted-syntax - wrapper.setData({ - initialRender: false, - filterValue: [tokenValueEmpty], - }); + wrapper.find(GlFilteredSearch).vm.$emit('clear'); - await nextTick(); - expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); - }); + await nextTick(); + expect(wrapper.emitted('onFilter')[0]).toEqual([[], true]); }); }); diff --git a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js index b67385cc43e..e636f58d868 100644 --- a/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js +++ b/spec/frontend/vue_shared/components/form/input_copy_toggle_visibility_spec.js @@ -89,8 +89,11 @@ describe('InputCopyToggleVisibility', () => { }); describe('when clicked', () => { + let event; + beforeEach(async () => { - await findRevealButton().trigger('click'); + event = { stopPropagation: jest.fn() }; + await findRevealButton().trigger('click', event); }); it('displays value', () => { @@ -110,6 +113,11 @@ describe('InputCopyToggleVisibility', () => { it('emits `visibility-change` event', () => { expect(wrapper.emitted('visibility-change')[0]).toEqual([true]); }); + + it('stops propagation on click event', () => { + // in case the input is located in a dropdown or modal + expect(event.stopPropagation).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/vue_shared/components/help_popover_spec.js b/spec/frontend/vue_shared/components/help_popover_spec.js index 597fb63d95c..64dce194327 100644 --- a/spec/frontend/vue_shared/components/help_popover_spec.js +++ b/spec/frontend/vue_shared/components/help_popover_spec.js @@ -34,7 +34,7 @@ describe('HelpPopover', () => { it('renders a link button with an icon question', () => { expect(findQuestionButton().props()).toMatchObject({ - icon: 'question', + icon: 'question-o', variant: 'link', }); }); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js deleted file mode 100644 index 24fc3713e2b..00000000000 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import IdenticonComponent from '~/vue_shared/components/identicon.vue'; - -describe('Identicon', () => { - let wrapper; - - const defaultProps = { - entityId: 1, - entityName: 'entity-name', - sizeClass: 's40', - }; - - const createComponent = (props = {}) => { - wrapper = shallowMount(IdenticonComponent, { - propsData: { - ...defaultProps, - ...props, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('entity id is a number', () => { - beforeEach(() => createComponent()); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); - - describe('entity id is a GraphQL id', () => { - beforeEach(() => createComponent({ entityId: 'gid://gitlab/Project/8' })); - - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('adds a correct class to identicon', () => { - expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/line_numbers_spec.js b/spec/frontend/vue_shared/components/line_numbers_spec.js deleted file mode 100644 index 38c26226863..00000000000 --- a/spec/frontend/vue_shared/components/line_numbers_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon, GlLink } from '@gitlab/ui'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; - -describe('Line Numbers component', () => { - let wrapper; - const lines = 10; - - const createComponent = () => { - wrapper = shallowMount(LineNumbers, { propsData: { lines } }); - }; - - const findGlIcon = () => wrapper.findComponent(GlIcon); - const findLineNumbers = () => wrapper.findAllComponents(GlLink); - const findFirstLineNumber = () => findLineNumbers().at(0); - - beforeEach(() => createComponent()); - - afterEach(() => wrapper.destroy()); - - describe('rendering', () => { - it('renders Line Numbers', () => { - expect(findLineNumbers().length).toBe(lines); - expect(findFirstLineNumber().attributes()).toMatchObject({ - id: 'L1', - to: '#LC1', - }); - }); - - it('renders a link icon', () => { - expect(findGlIcon().props()).toMatchObject({ - size: 12, - name: 'link', - }); - }); - }); -}); 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 dac633fe6c8..a80717a1aea 100644 --- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js +++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js @@ -1,31 +1,29 @@ import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +const STORAGE_KEY = 'key'; + describe('Local Storage Sync', () => { let wrapper; - const createComponent = ({ props = {}, slots = {} } = {}) => { + const createComponent = ({ value, asString = false, slots = {} } = {}) => { wrapper = shallowMount(LocalStorageSync, { - propsData: props, + propsData: { storageKey: STORAGE_KEY, value, asString }, slots, }); }; + const setStorageValue = (value) => localStorage.setItem(STORAGE_KEY, value); + const getStorageValue = (value) => localStorage.getItem(STORAGE_KEY, value); + afterEach(() => { - if (wrapper) { - wrapper.destroy(); - } - wrapper = null; + wrapper.destroy(); localStorage.clear(); }); it('is a renderless component', () => { const html = '
      '; createComponent({ - props: { - storageKey: 'key', - }, slots: { default: html, }, @@ -35,233 +33,136 @@ describe('Local Storage Sync', () => { }); describe('localStorage empty', () => { - const storageKey = 'issue_list_order'; - it('does not emit input event', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - - expect(wrapper.emitted('input')).toBeFalsy(); - }); - - it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })( - 'saves updated value to localStorage', - async (newValue) => { - createComponent({ - props: { - storageKey, - value: 'initial', - }, - }); - - wrapper.setProps({ value: newValue }); + createComponent({ value: 'ascending' }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(String(newValue)); - }, - ); - - it('does not save default value', () => { - const value = 'ascending'; + expect(wrapper.emitted('input')).toBeUndefined(); + }); - createComponent({ - props: { - storageKey, - value, - }, - }); + it('does not save initial value if it did not change', () => { + createComponent({ value: 'ascending' }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); describe('localStorage has saved value', () => { - const storageKey = 'issue_list_order_by'; const savedValue = 'last_updated'; beforeEach(() => { - localStorage.setItem(storageKey, savedValue); + setStorageValue(savedValue); + createComponent({ asString: true }); }); it('emits input event with saved value', () => { - createComponent({ - props: { - storageKey, - value: 'ascending', - }, - }); - expect(wrapper.emitted('input')[0][0]).toBe(savedValue); }); - it('does not overwrite localStorage with prop value', () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - - expect(localStorage.getItem(storageKey)).toBe(savedValue); + it('does not overwrite localStorage with initial prop value', () => { + expect(getStorageValue()).toBe(savedValue); }); it('updating the value updates localStorage', async () => { - createComponent({ - props: { - storageKey, - value: 'created', - }, - }); - const newValue = 'last_updated'; - wrapper.setProps({ - value: newValue, - }); + await wrapper.setProps({ value: newValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(newValue); + expect(getStorageValue()).toBe(newValue); }); + }); + describe('persist prop', () => { it('persists the value by default', async () => { const persistedValue = 'persisted'; + createComponent({ asString: true }); + // Sanity check to make sure we start with nothing saved. + expect(getStorageValue()).toBeNull(); - createComponent({ - props: { - storageKey, - }, - }); + await wrapper.setProps({ value: persistedValue }); - wrapper.setProps({ value: persistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).toBe(persistedValue); + expect(getStorageValue()).toBe(persistedValue); }); it('does not save a value if persist is set to false', async () => { + const value = 'saved'; const notPersistedValue = 'notPersisted'; + createComponent({ asString: true }); + // Save some value so we can test that it's not overwritten. + await wrapper.setProps({ value }); - createComponent({ - props: { - storageKey, - }, - }); + expect(getStorageValue()).toBe(value); - wrapper.setProps({ persist: false, value: notPersistedValue }); - await nextTick(); - expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue); + await wrapper.setProps({ persist: false, value: notPersistedValue }); + + expect(getStorageValue()).toBe(value); }); }); - describe('with "asJson" prop set to "true"', () => { - const storageKey = 'testStorageKey'; - - describe.each` - value | serializedValue - ${null} | ${'null'} - ${''} | ${'""'} - ${true} | ${'true'} - ${false} | ${'false'} - ${42} | ${'42'} - ${'42'} | ${'"42"'} - ${'{ foo: '} | ${'"{ foo: "'} - ${['test']} | ${'["test"]'} - ${{ foo: 'bar' }} | ${'{"foo":"bar"}'} - `('given $value', ({ value, serializedValue }) => { - describe('is a new value', () => { - beforeEach(async () => { - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - - wrapper.setProps({ value }); - - await nextTick(); - }); - - it('serializes the value correctly to localStorage', () => { - expect(localStorage.getItem(storageKey)).toBe(serializedValue); - }); - }); - - describe('is already stored', () => { - beforeEach(() => { - localStorage.setItem(storageKey, serializedValue); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('emits an input event with the deserialized value', () => { - expect(wrapper.emitted('input')).toEqual([[value]]); - }); - }); + describe('saving and restoring', () => { + it.each` + value | asString + ${'foo'} | ${true} + ${'foo'} | ${false} + ${'{ a: 1 }'} | ${true} + ${'{ a: 1 }'} | ${false} + ${3} | ${false} + ${['foo', 'bar']} | ${false} + ${{ foo: 'bar' }} | ${false} + ${null} | ${false} + ${' '} | ${false} + ${true} | ${false} + ${false} | ${false} + ${42} | ${false} + ${'42'} | ${false} + ${'{ foo: '} | ${false} + `('saves and restores the same value', async ({ value, asString }) => { + // Create an initial component to save the value. + createComponent({ asString }); + await wrapper.setProps({ value }); + wrapper.destroy(); + // Create a second component to restore the value. Restore is only done once, when the + // component is first mounted. + createComponent({ asString }); + + expect(wrapper.emitted('input')[0][0]).toEqual(value); }); - describe('with bad JSON in storage', () => { - const badJSON = '{ badJSON'; - - beforeEach(() => { - jest.spyOn(console, 'warn').mockImplementation(); - localStorage.setItem(storageKey, badJSON); - - createComponent({ - props: { - storageKey, - value: 'initial', - asJson: true, - }, - }); - }); - - it('should console warn', () => { - // eslint-disable-next-line no-console - expect(console.warn).toHaveBeenCalledWith( - `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`, - badJSON, - ); - }); - - it('should not emit an input event', () => { - expect(wrapper.emitted('input')).toBeUndefined(); - }); + it('shows a warning when trying to save a non-string value when asString prop is true', async () => { + const spy = jest.spyOn(console, 'warn').mockImplementation(); + createComponent({ asString: true }); + await wrapper.setProps({ value: [] }); + + expect(spy).toHaveBeenCalled(); }); }); - it('clears localStorage when clear property is true', async () => { - const storageKey = 'key'; - const value = 'initial'; + describe('with bad JSON in storage', () => { + const badJSON = '{ badJSON'; + let spy; - createComponent({ - props: { - storageKey, - }, + beforeEach(() => { + spy = jest.spyOn(console, 'warn').mockImplementation(); + setStorageValue(badJSON); + createComponent(); }); - wrapper.setProps({ - value, + + it('should console warn', () => { + expect(spy).toHaveBeenCalled(); }); - await nextTick(); + it('should not emit an input event', () => { + expect(wrapper.emitted('input')).toBeUndefined(); + }); + }); - expect(localStorage.getItem(storageKey)).toBe(value); + it('clears localStorage when clear property is true', async () => { + const value = 'initial'; + createComponent({ asString: true }); + await wrapper.setProps({ value }); - wrapper.setProps({ - clear: true, - }); + expect(getStorageValue()).toBe(value); - await nextTick(); + await wrapper.setProps({ clear: true }); - expect(localStorage.getItem(storageKey)).toBe(null); + expect(getStorageValue()).toBeNull(); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js index c56628fcbcd..ecb2b37c3a5 100644 --- a/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js +++ b/spec/frontend/vue_shared/components/markdown/apply_suggestion_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlFormTextarea, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlFormTextarea, GlButton, GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ApplySuggestionComponent from '~/vue_shared/components/markdown/apply_suggestion.vue'; @@ -10,9 +10,10 @@ describe('Apply Suggestion component', () => { wrapper = shallowMount(ApplySuggestionComponent, { propsData: { ...propsData, ...props } }); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findTextArea = () => wrapper.find(GlFormTextarea); - const findApplyButton = () => wrapper.find(GlButton); + const findDropdown = () => wrapper.findComponent(GlDropdown); + const findTextArea = () => wrapper.findComponent(GlFormTextarea); + const findApplyButton = () => wrapper.findComponent(GlButton); + const findAlert = () => wrapper.findComponent(GlAlert); beforeEach(() => createWrapper()); @@ -53,6 +54,20 @@ describe('Apply Suggestion component', () => { }); }); + describe('error', () => { + it('displays an error message', () => { + const errorMessage = 'Error message'; + createWrapper({ errorMessage }); + + const alert = findAlert(); + + expect(alert.exists()).toBe(true); + expect(alert.props('variant')).toBe('danger'); + expect(alert.props('dismissible')).toBe(false); + expect(alert.text()).toBe(errorMessage); + }); + }); + describe('apply suggestion', () => { it('emits an apply event with no message if no message was added', () => { findTextArea().vm.$emit('input', null); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index b5daa389fc6..d1c4d777d44 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -85,7 +85,7 @@ describe('Markdown field component', () => { describe('mounted', () => { const previewHTML = `

      markdown preview

      - + `; let previewLink; let writeLink; @@ -101,6 +101,21 @@ describe('Markdown field component', () => { expect(subject.find('.zen-backdrop textarea').element).not.toBeNull(); }); + it('renders referenced commands on markdown preview', async () => { + axiosMock + .onPost(markdownPreviewPath) + .reply(200, { references: { users: [], commands: 'test command' } }); + + previewLink = getPreviewLink(); + previewLink.vm.$emit('click', { target: {} }); + + await axios.waitFor(markdownPreviewPath); + const referencedCommands = subject.find('[data-testid="referenced-commands"]'); + + expect(referencedCommands.exists()).toBe(true); + expect(referencedCommands.text()).toContain('test command'); + }); + describe('markdown preview', () => { beforeEach(() => { axiosMock.onPost(markdownPreviewPath).reply(200, { body: previewHTML }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 9ffb9c6a541..fa4ca63f910 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -95,7 +95,7 @@ describe('Markdown field header component', () => { it('hides toolbar in preview mode', () => { createWrapper({ previewMarkdown: true }); - expect(findToolbar().classes().includes('gl-display-none')).toBe(true); + expect(findToolbar().classes().includes('gl-display-none!')).toBe(true); }); it('emits toggle markdown event when clicking preview tab', async () => { diff --git a/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap new file mode 100644 index 00000000000..5dd12d9edf5 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/__snapshots__/metric_images_table_spec.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Metrics upload item render the metrics image component 1`] = ` + + + +

      + Are you sure you wish to delete this image? +

      +
      + + + + + + + + + + + + +
      + +
      +
      +`; diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js new file mode 100644 index 00000000000..2cefa77b72d --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_tab_spec.js @@ -0,0 +1,174 @@ +import { GlFormInput, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import MetricImagesTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue'; +import createStore from '~/vue_shared/components/metric_images/store'; +import waitForPromises from 'helpers/wait_for_promises'; +import UploadDropzone from '~/vue_shared/components/upload_dropzone/upload_dropzone.vue'; +import { fileList, initialData } from './mock_data'; + +const service = { + getMetricImages: jest.fn(), +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metric images tab', () => { + let wrapper; + let store; + + const mountComponent = (options = {}) => { + store = createStore({}, service); + + wrapper = shallowMount( + MetricImagesTab, + merge( + { + store, + provide: { + canUpdate: true, + iid: initialData.issueIid, + projectId: initialData.projectId, + }, + }, + options, + ), + ); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findUploadDropzone = () => wrapper.findComponent(UploadDropzone); + const findImages = () => wrapper.findAllComponents(MetricImagesTable); + const findModal = () => wrapper.findComponent(GlModal); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const cancelModal = () => findModal().vm.$emit('hidden'); + + describe('empty state', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the upload component', () => { + expect(findUploadDropzone().exists()).toBe(true); + }); + }); + + describe('permissions', () => { + beforeEach(() => { + mountComponent({ provide: { canUpdate: false } }); + }); + + it('hides the upload component when disallowed', () => { + expect(findUploadDropzone().exists()).toBe(false); + }); + }); + + describe('onLoad action', () => { + it('should load images', async () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + mountComponent(); + + await waitForPromises(); + + expect(findImages().length).toBe(1); + }); + }); + + describe('add metric dialog', () => { + const testUrl = 'test url'; + + it('should open the add metric dialog when clicked', async () => { + mountComponent(); + + findUploadDropzone().vm.$emit('change'); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + it('should close when cancelled', async () => { + mountComponent({ + data() { + return { modalVisible: true }; + }, + }); + + cancelModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should add files and url when selected', async () => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl, currentFiles: fileList }; + }, + }); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('uploadImage', { + files: fileList, + url: testUrl, + urlText: '', + }); + }); + + describe('url field', () => { + beforeEach(() => { + mountComponent({ + data() { + return { modalVisible: true, modalUrl: testUrl }; + }, + }); + }); + + it('should display the url field', () => { + expect(wrapper.find('#upload-url-input').attributes('value')).toBe(testUrl); + }); + + it('should display the url text field', () => { + expect(wrapper.find('#upload-text-input').attributes('value')).toBe(''); + }); + + it('should clear url when cancelled', async () => { + cancelModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + + it('should clear url when submitted', async () => { + submitModal(); + + await waitForPromises(); + + expect(wrapper.findComponent(GlFormInput).attributes('value')).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js new file mode 100644 index 00000000000..d792bd46ccd --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/metric_images_table_spec.js @@ -0,0 +1,230 @@ +import { GlLink, GlModal } from '@gitlab/ui'; +import { shallowMount, mount } from '@vue/test-utils'; +import Vue from 'vue'; +import merge from 'lodash/merge'; +import Vuex from 'vuex'; +import createStore from '~/vue_shared/components/metric_images/store'; +import MetricsImageTable from '~/vue_shared/components/metric_images/metric_images_table.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +const defaultProps = { + id: 1, + filePath: 'test_file_path', + filename: 'test_file_name', +}; + +const mockEvent = { preventDefault: jest.fn() }; + +Vue.use(Vuex); + +describe('Metrics upload item', () => { + let wrapper; + let store; + + const mountComponent = (options = {}, mountMethod = mount) => { + store = createStore(); + + wrapper = mountMethod( + MetricsImageTable, + merge( + { + store, + propsData: { + ...defaultProps, + }, + provide: { canUpdate: true }, + }, + options, + ), + ); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findImageLink = () => wrapper.findComponent(GlLink); + const findLabelTextSpan = () => wrapper.find('[data-testid="metric-image-label-span"]'); + const findCollapseButton = () => wrapper.find('[data-testid="collapse-button"]'); + const findMetricImageBody = () => wrapper.find('[data-testid="metric-image-body"]'); + const findModal = () => wrapper.findComponent(GlModal); + const findEditModal = () => wrapper.find('[data-testid="metric-image-edit-modal"]'); + const findDeleteButton = () => wrapper.find('[data-testid="delete-button"]'); + const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); + const findImageTextInput = () => wrapper.find('[data-testid="metric-image-text-field"]'); + const findImageUrlInput = () => wrapper.find('[data-testid="metric-image-url-field"]'); + + const closeModal = () => findModal().vm.$emit('hidden'); + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const deleteImage = () => findDeleteButton().vm.$emit('click'); + const closeEditModal = () => findEditModal().vm.$emit('hidden'); + const submitEditModal = () => findEditModal().vm.$emit('primary', mockEvent); + const editImage = () => findEditButton().vm.$emit('click'); + + it('render the metrics image component', () => { + mountComponent({}, shallowMount); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('shows a link with the correct url', () => { + const testUrl = 'test_url'; + mountComponent({ propsData: { url: testUrl } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(defaultProps.filename); + }); + + it('shows a link with the url text, if url text is present', () => { + const testUrl = 'test_url'; + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { url: testUrl, urlText: testUrlText } }); + + expect(findImageLink().attributes('href')).toBe(testUrl); + expect(findImageLink().text()).toBe(testUrlText); + }); + + it('shows the url text with no url, if no url is present', () => { + const testUrlText = 'test_url_text'; + mountComponent({ propsData: { urlText: testUrlText } }); + + expect(findLabelTextSpan().text()).toBe(testUrlText); + }); + + describe('expand and collapse', () => { + beforeEach(() => { + mountComponent(); + }); + + it('the card is expanded by default', () => { + expect(findMetricImageBody().isVisible()).toBe(true); + }); + + it('the card is collapsed when clicked', async () => { + findCollapseButton().trigger('click'); + + await waitForPromises(); + + expect(findMetricImageBody().isVisible()).toBe(false); + }); + }); + + describe('delete functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + deleteImage(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent( + { + data() { + return { modalVisible: true }; + }, + }, + shallowMount, + ); + }); + + it('should close the modal when cancelled', async () => { + closeModal(); + + await waitForPromises(); + + expect(findModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('deleteImage', defaultProps.id); + }); + }); + + describe('canUpdate permission', () => { + it('delete button is hidden when user lacks update permissions', () => { + mountComponent({ provide: { canUpdate: false } }); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + }); + + describe('edit functionality', () => { + it('should open the delete modal when clicked', async () => { + mountComponent({ stubs: { GlModal: true } }); + + editImage(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBe('true'); + }); + + describe('when the modal is open', () => { + beforeEach(() => { + mountComponent({ + data() { + return { editModalVisible: true }; + }, + propsData: { urlText: 'test' }, + stubs: { GlModal: true }, + }); + }); + + it('should close the modal when cancelled', async () => { + closeEditModal(); + + await waitForPromises(); + + expect(findEditModal().attributes('visible')).toBeFalsy(); + }); + + it('should delete the image when selected', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch').mockImplementation(jest.fn()); + + submitEditModal(); + + await waitForPromises(); + + expect(dispatchSpy).toHaveBeenCalledWith('updateImage', { + imageId: defaultProps.id, + url: null, + urlText: 'test', + }); + }); + + it('should clear edits when the modal is closed', async () => { + await findImageTextInput().setValue('test value'); + await findImageUrlInput().setValue('http://www.gitlab.com'); + + expect(findImageTextInput().element.value).toBe('test value'); + expect(findImageUrlInput().element.value).toBe('http://www.gitlab.com'); + + closeEditModal(); + + await waitForPromises(); + + editImage(); + + await waitForPromises(); + + expect(findImageTextInput().element.value).toBe('test'); + expect(findImageUrlInput().element.value).toBe(''); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/mock_data.js b/spec/frontend/vue_shared/components/metric_images/mock_data.js new file mode 100644 index 00000000000..480491077fb --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/mock_data.js @@ -0,0 +1,5 @@ +export const fileList = [{ filePath: 'test', filename: 'hello', id: 5, url: null }]; + +export const fileListRaw = [{ file_path: 'test', filename: 'hello', id: 5, url: null }]; + +export const initialData = { issueIid: '123', projectId: 456 }; diff --git a/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js new file mode 100644 index 00000000000..518cf354675 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/actions_spec.js @@ -0,0 +1,158 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actionsFactory from '~/vue_shared/components/metric_images/store/actions'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import createStore from '~/vue_shared/components/metric_images/store'; +import testAction from 'helpers/vuex_action_helper'; +import createFlash from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { fileList, initialData } from '../mock_data'; + +jest.mock('~/flash'); +const service = { + getMetricImages: jest.fn(), + uploadMetricImage: jest.fn(), + updateMetricImage: jest.fn(), + deleteMetricImage: jest.fn(), +}; + +const actions = actionsFactory(service); + +const defaultState = { + issueIid: 1, + projectId: '2', +}; + +Vue.use(Vuex); + +describe('Metrics tab store actions', () => { + let store; + let state; + + beforeEach(() => { + store = createStore(defaultState); + state = store.state; + }); + + afterEach(() => { + createFlash.mockClear(); + }); + + describe('fetching metric images', () => { + it('should call success action when fetching metric images', () => { + service.getMetricImages.mockImplementation(() => Promise.resolve(fileList)); + + testAction(actions.fetchImages, null, state, [ + { type: types.REQUEST_METRIC_IMAGES }, + { + type: types.RECEIVE_METRIC_IMAGES_SUCCESS, + payload: convertObjectPropsToCamelCase(fileList, { deep: true }), + }, + ]); + }); + + it('should call error action when fetching metric images with an error', async () => { + service.getMetricImages.mockImplementation(() => Promise.reject()); + + await testAction( + actions.fetchImages, + null, + state, + [{ type: types.REQUEST_METRIC_IMAGES }, { type: types.RECEIVE_METRIC_IMAGES_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('uploading metric images', () => { + const payload = { + // mock the FileList api + files: { + item() { + return fileList[0]; + }, + }, + url: 'test_url', + }; + + it('should call success action when uploading an image', () => { + service.uploadMetricImage.mockImplementation(() => Promise.resolve(fileList[0])); + + testAction(actions.uploadImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPLOAD_SUCCESS, + payload: fileList[0], + }, + ]); + }); + + it('should call error action when failing to upload an image', async () => { + service.uploadMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.uploadImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('updating metric images', () => { + const payload = { + url: 'test_url', + urlText: 'url text', + }; + + it('should call success action when updating an image', () => { + service.updateMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.updateImage, payload, state, [ + { type: types.REQUEST_METRIC_UPLOAD }, + { + type: types.RECEIVE_METRIC_UPDATE_SUCCESS, + }, + ]); + }); + + it('should call error action when failing to update an image', async () => { + service.updateMetricImage.mockImplementation(() => Promise.reject()); + + await testAction( + actions.updateImage, + payload, + state, + [{ type: types.REQUEST_METRIC_UPLOAD }, { type: types.RECEIVE_METRIC_UPLOAD_ERROR }], + [], + ); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('deleting a metric image', () => { + const payload = fileList[0].id; + + it('should call success action when deleting an image', () => { + service.deleteMetricImage.mockImplementation(() => Promise.resolve()); + + testAction(actions.deleteImage, payload, state, [ + { + type: types.RECEIVE_METRIC_DELETE_SUCCESS, + payload, + }, + ]); + }); + }); + + describe('initial data', () => { + it('should set the initial data correctly', () => { + testAction(actions.setInitialData, initialData, state, [ + { type: types.SET_INITIAL_DATA, payload: initialData }, + ]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js new file mode 100644 index 00000000000..754f729e657 --- /dev/null +++ b/spec/frontend/vue_shared/components/metric_images/store/mutations_spec.js @@ -0,0 +1,147 @@ +import { cloneDeep } from 'lodash'; +import * as types from '~/vue_shared/components/metric_images/store/mutation_types'; +import mutations from '~/vue_shared/components/metric_images/store/mutations'; +import { initialData } from '../mock_data'; + +const defaultState = { + metricImages: [], + isLoadingMetricImages: false, + isUploadingImage: false, +}; + +const testImages = [ + { filename: 'test.filename', id: 5, filePath: 'test/file/path', url: null }, + { filename: 'second.filename', id: 6, filePath: 'second/file/path', url: 'test/url' }, + { filename: 'third.filename', id: 7, filePath: 'third/file/path', url: 'test/url' }, +]; + +describe('Metric images mutations', () => { + let state; + + const createState = (customState = {}) => { + state = { + ...cloneDeep(defaultState), + ...customState, + }; + }; + + beforeEach(() => { + createState(); + }); + + describe('REQUEST_METRIC_IMAGES', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_IMAGES](state); + }); + + it('should set the loading state', () => { + expect(state.isLoadingMetricImages).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_SUCCESS', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_SUCCESS](state, testImages); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + + it('should set the metric images', () => { + expect(state.metricImages).toEqual(testImages); + }); + }); + + describe('RECEIVE_METRIC_IMAGES_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_IMAGES_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isLoadingMetricImages).toBe(false); + }); + }); + + describe('REQUEST_METRIC_UPLOAD', () => { + beforeEach(() => { + mutations[types.REQUEST_METRIC_UPLOAD](state); + }); + + it('should set the loading state', () => { + expect(state.isUploadingImage).toBe(true); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[1]; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPLOAD_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should add the new metric image after the existing one', () => { + expect(state.metricImages).toMatchObject([initialImage, newImage]); + }); + }); + + describe('RECEIVE_METRIC_UPLOAD_ERROR', () => { + beforeEach(() => { + mutations[types.RECEIVE_METRIC_UPLOAD_ERROR](state); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + }); + + describe('RECEIVE_METRIC_UPDATE_SUCCESS', () => { + const initialImage = testImages[0]; + const newImage = testImages[0]; + newImage.url = 'https://www.gitlab.com'; + + beforeEach(() => { + createState({ metricImages: [initialImage] }); + mutations[types.RECEIVE_METRIC_UPDATE_SUCCESS](state, newImage); + }); + + it('should unset the loading state', () => { + expect(state.isUploadingImage).toBe(false); + }); + + it('should replace the existing image with the new one', () => { + expect(state.metricImages).toMatchObject([newImage]); + }); + }); + + describe('RECEIVE_METRIC_DELETE_SUCCESS', () => { + const deletedImageId = testImages[1].id; + const expectedResult = [testImages[0], testImages[2]]; + + beforeEach(() => { + createState({ metricImages: [...testImages] }); + mutations[types.RECEIVE_METRIC_DELETE_SUCCESS](state, deletedImageId); + }); + + it('should remove the correct metric image', () => { + expect(state.metricImages).toEqual(expectedResult); + }); + }); + + describe('SET_INITIAL_DATA', () => { + beforeEach(() => { + mutations[types.SET_INITIAL_DATA](state, initialData); + }); + + it('should unset the loading state', () => { + expect(state.modelIid).toBe(initialData.modelIid); + expect(state.projectId).toBe(initialData.projectId); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index c8dab0204d3..6881cb79740 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { userDataMock } from '../../../notes/mock_data'; +import { userDataMock } from 'jest/notes/mock_data'; Vue.use(Vuex); diff --git a/spec/frontend/vue_shared/components/project_avatar/default_spec.js b/spec/frontend/vue_shared/components/project_avatar/default_spec.js deleted file mode 100644 index d042db6051c..00000000000 --- a/spec/frontend/vue_shared/components/project_avatar/default_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import { projectData } from 'jest/ide/mock_data'; -import { TEST_HOST } from 'spec/test_constants'; -import { getFirstCharacterCapitalized } from '~/lib/utils/text_utility'; -import ProjectAvatarDefault from '~/vue_shared/components/deprecated_project_avatar/default.vue'; - -describe('ProjectAvatarDefault component', () => { - const Component = Vue.extend(ProjectAvatarDefault); - let vm; - - beforeEach(() => { - vm = mountComponent(Component, { - project: projectData, - }); - }); - - afterEach(() => { - vm.$destroy(); - }); - - it('renders identicon if project has no avatar_url', async () => { - const expectedText = getFirstCharacterCapitalized(projectData.name); - - vm.project = { - ...vm.project, - avatar_url: null, - }; - - await nextTick(); - const identiconEl = vm.$el.querySelector('.identicon'); - - expect(identiconEl).not.toBe(null); - expect(identiconEl.textContent.trim()).toEqual(expectedText); - }); - - it('renders avatar image if project has avatar_url', async () => { - const avatarUrl = `${TEST_HOST}/images/home/nasa.svg`; - - vm.project = { - ...vm.project, - avatar_url: avatarUrl, - }; - - await nextTick(); - expect(vm.$el.querySelector('.avatar')).not.toBeNull(); - expect(vm.$el.querySelector('.identicon')).toBeNull(); - expect(vm.$el.querySelector('img')).toHaveAttr('src', avatarUrl); - }); -}); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 5afa017aa76..397ab2254b9 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import mockProjects from 'test_fixtures_static/projects.json'; import { trimText } from 'helpers/text_helper'; -import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue'; +import ProjectAvatar from '~/vue_shared/components/project_avatar.vue'; import ProjectListItem from '~/vue_shared/components/project_selector/project_list_item.vue'; describe('ProjectListItem component', () => { @@ -52,8 +52,13 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); + const avatar = wrapper.findComponent(ProjectAvatar); - expect(wrapper.findComponent(ProjectAvatar).exists()).toBe(true); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + projectAvatarUrl: '', + projectName: project.name_with_namespace, + }); }); it(`renders a simple namespace name with a trailing slash`, () => { diff --git a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js index c65ded000d3..616fefe847e 100644 --- a/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js +++ b/spec/frontend/vue_shared/components/registry/persisted_dropdown_selection_spec.js @@ -36,10 +36,10 @@ describe('Persisted dropdown selection', () => { }); describe('local storage sync', () => { - it('uses the local storage sync component', () => { + it('uses the local storage sync component with the correct props', () => { createComponent(); - expect(findLocalStorageSync().exists()).toBe(true); + expect(findLocalStorageSync().props('asString')).toBe(true); }); it('passes the right props', () => { diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap index 6954bd5ccff..ac313e556fc 100644 --- a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -42,7 +42,7 @@ exports[`RunnerAwsDeploymentsModal renders the modal 1`] = `

      { }); describe('setInitialState', () => { - it('sets initial store state', (done) => { - testAction( + it('sets initial store state', () => { + return testAction( actions.setInitialState, mockInitialState, state, [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }], [], - done, ); }); }); describe('toggleDropdownButton', () => { - it('toggles dropdown button', (done) => { - testAction( + it('toggles dropdown button', () => { + return testAction( actions.toggleDropdownButton, {}, state, [{ type: types.TOGGLE_DROPDOWN_BUTTON }], [], - done, ); }); }); describe('toggleDropdownContents', () => { - it('toggles dropdown contents', (done) => { - testAction( + it('toggles dropdown contents', () => { + return testAction( actions.toggleDropdownContents, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS }], [], - done, ); }); }); describe('toggleDropdownContentsCreateView', () => { - it('toggles dropdown create view', (done) => { - testAction( + it('toggles dropdown create view', () => { + return testAction( actions.toggleDropdownContentsCreateView, {}, state, [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }], [], - done, ); }); }); describe('requestLabels', () => { - it('sets value of `state.labelsFetchInProgress` to `true`', (done) => { - testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done); + it('sets value of `state.labelsFetchInProgress` to `true`', () => { + return testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], []); }); }); describe('receiveLabelsSuccess', () => { - it('sets provided labels to `state.labels`', (done) => { + it('sets provided labels to `state.labels`', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return testAction( actions.receiveLabelsSuccess, labels, state, [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }], [], - done, ); }); }); describe('receiveLabelsFailure', () => { - it('sets value `state.labelsFetchInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelsFetchInProgress` to `false`', () => { + return testAction( actions.receiveLabelsFailure, {}, state, [{ type: types.RECEIVE_SET_LABELS_FAILURE }], [], - done, ); }); @@ -125,72 +119,67 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; mock.onGet(/labels.json/).replyOnce(200, labels); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => { + it('dispatches `requestLabels` & `receiveLabelsFailure` actions', () => { mock.onGet(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.fetchLabels, {}, state, [], [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }], - done, ); }); }); }); describe('requestCreateLabel', () => { - it('sets value `state.labelCreateInProgress` to `true`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `true`', () => { + return testAction( actions.requestCreateLabel, {}, state, [{ type: types.REQUEST_CREATE_LABEL }], [], - done, ); }); }); describe('receiveCreateLabelSuccess', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelSuccess, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_SUCCESS }], [], - done, ); }); }); describe('receiveCreateLabelFailure', () => { - it('sets value `state.labelCreateInProgress` to `false`', (done) => { - testAction( + it('sets value `state.labelCreateInProgress` to `false`', () => { + return testAction( actions.receiveCreateLabelFailure, {}, state, [{ type: types.RECEIVE_CREATE_LABEL_FAILURE }], [], - done, ); }); @@ -214,11 +203,11 @@ describe('LabelsSelect Actions', () => { }); describe('on success', () => { - it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', (done) => { + it('dispatches `requestCreateLabel`, `fetchLabels` & `receiveCreateLabelSuccess` & `toggleDropdownContentsCreateView` actions', () => { const label = { id: 1 }; mock.onPost(/labels.json/).replyOnce(200, label); - testAction( + return testAction( actions.createLabel, {}, state, @@ -229,38 +218,35 @@ describe('LabelsSelect Actions', () => { { type: 'receiveCreateLabelSuccess' }, { type: 'toggleDropdownContentsCreateView' }, ], - done, ); }); }); describe('on failure', () => { - it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', (done) => { + it('dispatches `requestCreateLabel` & `receiveCreateLabelFailure` actions', () => { mock.onPost(/labels.json/).replyOnce(500, {}); - testAction( + return testAction( actions.createLabel, {}, state, [], [{ type: 'requestCreateLabel' }, { type: 'receiveCreateLabelFailure' }], - done, ); }); }); }); describe('updateSelectedLabels', () => { - it('updates `state.labels` based on provided `labels` param', (done) => { + it('updates `state.labels` based on provided `labels` param', () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; - testAction( + return testAction( actions.updateSelectedLabels, labels, state, [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }], [], - done, ); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js index 67e1a3ce932..1b27a294b90 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js @@ -11,9 +11,15 @@ import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/ import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; -import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse } from './mock_data'; +import { + mockConfig, + issuableLabelsQueryResponse, + updateLabelsMutationResponse, + issuableLabelsSubscriptionResponse, +} from './mock_data'; jest.mock('~/flash'); @@ -21,6 +27,7 @@ Vue.use(VueApollo); const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse); const successfulMutationHandler = jest.fn().mockResolvedValue(updateLabelsMutationResponse); +const subscriptionHandler = jest.fn().mockResolvedValue(issuableLabelsSubscriptionResponse); const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const updateLabelsMutation = { @@ -42,10 +49,12 @@ describe('LabelsSelectRoot', () => { issuableType = IssuableType.Issue, queryHandler = successfulQueryHandler, mutationHandler = successfulMutationHandler, + isRealtimeEnabled = false, } = {}) => { const mockApollo = createMockApollo([ [issueLabelsQuery, queryHandler], [updateLabelsMutation[issuableType], mutationHandler], + [issuableLabelsSubscription, subscriptionHandler], ]); wrapper = shallowMount(LabelsSelectRoot, { @@ -65,6 +74,9 @@ describe('LabelsSelectRoot', () => { allowLabelEdit: true, allowLabelCreate: true, labelsManagePath: 'test', + glFeatures: { + realtimeLabels: isRealtimeEnabled, + }, }, }); }; @@ -190,5 +202,26 @@ describe('LabelsSelectRoot', () => { message: 'An error occurred while updating labels.', }); }); + + it('does not emit `updateSelectedLabels` event when the subscription is triggered and FF is disabled', async () => { + createComponent(); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toBeUndefined(); + }); + + it('emits `updateSelectedLabels` event when the subscription is triggered and FF is enabled', async () => { + createComponent({ isRealtimeEnabled: true }); + await waitForPromises(); + + expect(wrapper.emitted('updateSelectedLabels')).toEqual([ + [ + { + id: '1', + labels: issuableLabelsSubscriptionResponse.data.issuableLabelsUpdated.labels.nodes, + }, + ], + ]); + }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js index 49224fb915c..afad9314ace 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js @@ -141,6 +141,34 @@ export const issuableLabelsQueryResponse = { }, }; +export const issuableLabelsSubscriptionResponse = { + data: { + issuableLabelsUpdated: { + id: '1', + labels: { + nodes: [ + { + __typename: 'Label', + color: '#330066', + description: null, + id: 'gid://gitlab/ProjectLabel/1', + title: 'Label1', + textColor: '#000000', + }, + { + __typename: 'Label', + color: '#000000', + description: null, + id: 'gid://gitlab/ProjectLabel/2', + title: 'Label2', + textColor: '#ffffff', + }, + ], + }, + }, + }, +}; + export const updateLabelsMutationResponse = { data: { updateIssuableLabels: { diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js new file mode 100644 index 00000000000..eb2eec92534 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_line_spec.js @@ -0,0 +1,69 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; +import { + BIDI_CHARS, + BIDI_CHARS_CLASS_LIST, + BIDI_CHAR_TOOLTIP, +} from '~/vue_shared/components/source_viewer/constants'; + +const DEFAULT_PROPS = { + number: 2, + content: '// Line content', + language: 'javascript', +}; + +describe('Chunk Line component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(ChunkLine, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findLink = () => wrapper.findComponent(GlLink); + const findContent = () => wrapper.findByTestId('content'); + const findWrappedBidiChars = () => wrapper.findAllByTestId('bidi-wrapper'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('rendering', () => { + it('wraps BiDi characters', () => { + const content = `// some content ${BIDI_CHARS.toString()} with BiDi chars`; + createComponent({ content }); + const wrappedBidiChars = findWrappedBidiChars(); + + expect(wrappedBidiChars.length).toBe(BIDI_CHARS.length); + + wrappedBidiChars.wrappers.forEach((_, i) => { + expect(wrappedBidiChars.at(i).text()).toBe(BIDI_CHARS[i]); + expect(wrappedBidiChars.at(i).attributes()).toMatchObject({ + class: BIDI_CHARS_CLASS_LIST, + title: BIDI_CHAR_TOOLTIP, + }); + }); + }); + + it('renders a line number', () => { + expect(findLink().attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.number}`, + to: `#L${DEFAULT_PROPS.number}`, + id: `L${DEFAULT_PROPS.number}`, + }); + + expect(findLink().text()).toBe(DEFAULT_PROPS.number.toString()); + }); + + it('renders content', () => { + expect(findContent().attributes()).toMatchObject({ + id: `LC${DEFAULT_PROPS.number}`, + lang: DEFAULT_PROPS.language, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js new file mode 100644 index 00000000000..42c4f2eacb8 --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -0,0 +1,82 @@ +import { GlIntersectionObserver } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; +import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; + +const DEFAULT_PROPS = { + chunkIndex: 2, + isHighlighted: false, + content: '// Line 1 content \n // Line 2 content', + startingFrom: 140, + totalLines: 50, + language: 'javascript', +}; + +describe('Chunk component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMountExtended(Chunk, { propsData: { ...DEFAULT_PROPS, ...props } }); + }; + + const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver); + const findChunkLines = () => wrapper.findAllComponents(ChunkLine); + const findLineNumbers = () => wrapper.findAllByTestId('line-number'); + const findContent = () => wrapper.findByTestId('content'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => wrapper.destroy()); + + describe('Intersection observer', () => { + it('renders an Intersection observer component', () => { + expect(findIntersectionObserver().exists()).toBe(true); + }); + + it('emits an appear event when intersection-observer appears', () => { + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual([[DEFAULT_PROPS.chunkIndex]]); + }); + + it('does not emit an appear event is isHighlighted is true', () => { + createComponent({ isHighlighted: true }); + findIntersectionObserver().vm.$emit('appear'); + + expect(wrapper.emitted('appear')).toEqual(undefined); + }); + }); + + describe('rendering', () => { + it('does not render a Chunk Line component if isHighlighted is false', () => { + expect(findChunkLines().length).toBe(0); + }); + + it('renders simplified line numbers and content if isHighlighted is false', () => { + expect(findLineNumbers().length).toBe(DEFAULT_PROPS.totalLines); + + expect(findLineNumbers().at(0).attributes()).toMatchObject({ + 'data-line-number': `${DEFAULT_PROPS.startingFrom + 1}`, + href: `#L${DEFAULT_PROPS.startingFrom + 1}`, + id: `L${DEFAULT_PROPS.startingFrom + 1}`, + }); + + expect(findContent().text()).toBe(DEFAULT_PROPS.content); + }); + + it('renders Chunk Line components if isHighlighted is true', () => { + const splitContent = DEFAULT_PROPS.content.split('\n'); + createComponent({ isHighlighted: true }); + + expect(findChunkLines().length).toBe(splitContent.length); + + expect(findChunkLines().at(0).props()).toMatchObject({ + number: DEFAULT_PROPS.startingFrom + 1, + content: splitContent[0], + language: DEFAULT_PROPS.language, + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js index ab579945e22..6a9ea75127d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/source_viewer_spec.js @@ -1,24 +1,38 @@ import hljs from 'highlight.js/lib/core'; -import { GlLoadingIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SourceViewer from '~/vue_shared/components/source_viewer/source_viewer.vue'; +import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; import { ROUGE_TO_HLJS_LANGUAGE_MAP } from '~/vue_shared/components/source_viewer/constants'; -import LineNumbers from '~/vue_shared/components/line_numbers.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import * as sourceViewerUtils from '~/vue_shared/components/source_viewer/utils'; +import LineHighlighter from '~/blob/line_highlighter'; +import eventHub from '~/notes/event_hub'; +jest.mock('~/blob/line_highlighter'); jest.mock('highlight.js/lib/core'); Vue.use(VueRouter); const router = new VueRouter(); +const generateContent = (content, totalLines = 1) => { + let generatedContent = ''; + for (let i = 0; i < totalLines; i += 1) { + generatedContent += `Line: ${i + 1} = ${content}\n`; + } + return generatedContent; +}; + +const execImmediately = (callback) => callback(); + describe('Source Viewer component', () => { let wrapper; const language = 'docker'; const mappedLanguage = ROUGE_TO_HLJS_LANGUAGE_MAP[language]; - const content = `// Some source code`; - const DEFAULT_BLOB_DATA = { language, rawTextBlob: content }; + const chunk1 = generateContent('// Some source code 1', 70); + const chunk2 = generateContent('// Some source code 2', 70); + const content = chunk1 + chunk2; + const path = 'some/path.js'; + const DEFAULT_BLOB_DATA = { language, rawTextBlob: content, path }; const highlightedContent = `${content}`; const createComponent = async (blob = {}) => { @@ -29,15 +43,13 @@ describe('Source Viewer component', () => { await waitForPromises(); }; - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findLineNumbers = () => wrapper.findComponent(LineNumbers); - const findHighlightedContent = () => wrapper.findByTestId('test-highlighted'); - const findFirstLine = () => wrapper.find('#LC1'); + const findChunks = () => wrapper.findAllComponents(Chunk); beforeEach(() => { hljs.highlight.mockImplementation(() => ({ value: highlightedContent })); hljs.highlightAuto.mockImplementation(() => ({ value: highlightedContent })); - jest.spyOn(sourceViewerUtils, 'wrapLines'); + jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); + jest.spyOn(eventHub, '$emit'); return createComponent(); }); @@ -45,6 +57,8 @@ describe('Source Viewer component', () => { afterEach(() => wrapper.destroy()); describe('highlight.js', () => { + beforeEach(() => createComponent({ language: mappedLanguage })); + it('registers the language definition', async () => { const languageDefinition = await import(`highlight.js/lib/languages/${mappedLanguage}`); @@ -54,72 +68,51 @@ describe('Source Viewer component', () => { ); }); - it('highlights the content', () => { - expect(hljs.highlight).toHaveBeenCalledWith(content, { language: mappedLanguage }); + it('highlights the first chunk', () => { + expect(hljs.highlight).toHaveBeenCalledWith(chunk1.trim(), { language: mappedLanguage }); }); describe('auto-detects if a language cannot be loaded', () => { beforeEach(() => createComponent({ language: 'some_unknown_language' })); it('highlights the content with auto-detection', () => { - expect(hljs.highlightAuto).toHaveBeenCalledWith(content); + expect(hljs.highlightAuto).toHaveBeenCalledWith(chunk1.trim()); }); }); }); describe('rendering', () => { - it('renders a loading icon if no highlighted content is available yet', async () => { - hljs.highlight.mockImplementation(() => ({ value: null })); - await createComponent(); - - expect(findLoadingIcon().exists()).toBe(true); - }); + it('renders the first chunk', async () => { + const firstChunk = findChunks().at(0); - it('calls the wrapLines helper method with highlightedContent and mappedLanguage', () => { - expect(sourceViewerUtils.wrapLines).toHaveBeenCalledWith(highlightedContent, mappedLanguage); - }); - - it('renders Line Numbers', () => { - expect(findLineNumbers().props('lines')).toBe(1); - }); + expect(firstChunk.props('content')).toContain(chunk1); - it('renders the highlighted content', () => { - expect(findHighlightedContent().exists()).toBe(true); + expect(firstChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 0, + }); }); - }); - describe('selecting a line', () => { - let firstLine; - let firstLineElement; + it('renders the second chunk', async () => { + const secondChunk = findChunks().at(1); - beforeEach(() => { - firstLine = findFirstLine(); - firstLineElement = firstLine.element; + expect(secondChunk.props('content')).toContain(chunk2.trim()); - jest.spyOn(firstLineElement, 'scrollIntoView'); - jest.spyOn(firstLineElement.classList, 'add'); - jest.spyOn(firstLineElement.classList, 'remove'); - }); - - it('adds the highlight (hll) class', async () => { - wrapper.vm.$router.push('#LC1'); - await nextTick(); - - expect(firstLineElement.classList.add).toHaveBeenCalledWith('hll'); + expect(secondChunk.props()).toMatchObject({ + totalLines: 70, + startingFrom: 70, + }); }); + }); - it('removes the highlight (hll) class from a previously highlighted line', async () => { - wrapper.vm.$router.push('#LC2'); - await nextTick(); - - expect(firstLineElement.classList.remove).toHaveBeenCalledWith('hll'); - }); + it('emits showBlobInteractionZones on the eventHub when chunk appears', () => { + findChunks().at(0).vm.$emit('appear'); + expect(eventHub.$emit).toBeCalledWith('showBlobInteractionZones', path); + }); - it('scrolls the line into view', () => { - expect(firstLineElement.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'smooth', - block: 'center', - }); + describe('LineHighlighter', () => { + it('instantiates the lineHighlighter class', async () => { + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js b/spec/frontend/vue_shared/components/source_viewer/utils_spec.js deleted file mode 100644 index 0631e7efd54..00000000000 --- a/spec/frontend/vue_shared/components/source_viewer/utils_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import { wrapLines } from '~/vue_shared/components/source_viewer/utils'; - -describe('Wrap lines', () => { - it.each` - content | language | output - ${'line 1'} | ${'javascript'} | ${'line 1'} - ${'line 1\nline 2'} | ${'html'} | ${`line 1\nline 2`} - ${'line 1\nline 2'} | ${'html'} | ${`line 1\nline 2`} - ${'```bash'} | ${'bash'} | ${'```bash'} - ${'```bash'} | ${'valid-language1'} | ${'```bash'} - ${'```bash'} | ${'valid_language2'} | ${'```bash'} - `('returns lines wrapped in spans containing line numbers', ({ content, language, output }) => { - expect(wrapLines(content, language)).toBe(output); - }); - - it.each` - language - ${'invalidLanguage>'} - ${'"invalidLanguage"'} - ${' { - expect(wrapLines('```bash', language)).toBe( - '```bash', - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js index f624f84eabd..5e05b54cb8c 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_new_spec.js @@ -109,19 +109,33 @@ describe('User Avatar Image Component', () => { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: PROVIDED_PROPS, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); + + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); + + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js index 5051b2b9cae..2c1be6ec47e 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_image_old_spec.js @@ -90,33 +90,38 @@ describe('User Avatar Image Component', () => { }); }); - describe('dynamic tooltip content', () => { - const props = PROVIDED_PROPS; + describe('Dynamic tooltip content', () => { const slots = { default: ['Action!'], }; - beforeEach(() => { - wrapper = shallowMount(UserAvatarImage, { - propsData: { props }, - slots, + describe('when `tooltipText` is provided and no default slot', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + }); }); - }); - it('renders the tooltip slot', () => { - expect(wrapper.findComponent(GlTooltip).exists()).toBe(true); + it('renders the tooltip with `tooltipText` as content', () => { + expect(wrapper.findComponent(GlTooltip).text()).toBe(PROVIDED_PROPS.tooltipText); + }); }); - it('renders the tooltip content', () => { - expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); - }); + describe('when `tooltipText` and default slot is provided', () => { + beforeEach(() => { + wrapper = shallowMount(UserAvatarImage, { + propsData: { ...PROVIDED_PROPS }, + slots, + }); + }); - it('does not render tooltip data attributes on avatar image', () => { - const avatarImg = wrapper.find('img'); + it('does not render `tooltipText` inside the tooltip', () => { + expect(wrapper.findComponent(GlTooltip).text()).not.toBe(PROVIDED_PROPS.tooltipText); + }); - expect(avatarImg.attributes('title')).toBeFalsy(); - expect(avatarImg.attributes('data-placement')).not.toBeDefined(); - expect(avatarImg.attributes('data-container')).not.toBeDefined(); + it('renders the content provided via default slot', () => { + expect(wrapper.findComponent(GlTooltip).text()).toContain(slots.default[0]); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 66bb234aef6..20ff0848cff 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -153,4 +153,29 @@ describe('UserAvatarList', () => { }); }); }); + + describe('additional styling for the image', () => { + it('should not add CSS class when feature flag `glAvatarForAllUserAvatars` is disabled', () => { + factory({ + propsData: { items: createList(1) }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).not.toBe('gl-mr-3'); + }); + + it('should add CSS class when feature flag `glAvatarForAllUserAvatars` is enabled', () => { + factory({ + propsData: { items: createList(1) }, + provide: { + glFeatures: { + glAvatarForAllUserAvatars: true, + }, + }, + }); + + const link = wrapper.findComponent(UserAvatarLink); + expect(link.props('imgCssClasses')).toBe('gl-mr-3'); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index cb476910944..ec9128d5e38 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -16,7 +16,7 @@ import { searchResponseOnMR, projectMembersResponse, participantsQueryResponse, -} from '../../sidebar/mock_data'; +} from 'jest/sidebar/mock_data'; const assignee = { id: 'gid://gitlab/User/4', diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index e79935f8fa6..040461f6be4 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -261,7 +261,10 @@ describe('Web IDE link component', () => { }); it('should update local storage when selection changes', async () => { - expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key); + expect(findLocalStorageSync().props()).toMatchObject({ + asString: true, + value: ACTION_WEB_IDE.key, + }); findActionsButton().vm.$emit('select', ACTION_GITPOD.key); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js index 64823cd4c6c..058cb30c1d5 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_list_root_spec.js @@ -1,4 +1,9 @@ -import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui'; +import { + GlAlert, + GlKeysetPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlPagination, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import VueDraggable from 'vuedraggable'; 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 index 6af07273cf6..46bfd7eceb1 100644 --- 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 @@ -26,8 +26,8 @@ describe('sast report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('sast report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,9 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +121,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +131,10 @@ describe('sast report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +149,6 @@ describe('sast report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +162,13 @@ describe('sast report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveError` action', (done) => { - testAction( + it('should dispatch the `receiveError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +182,13 @@ describe('sast report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); 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 index d22fee864e7..4f4f653bb72 100644 --- 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 @@ -26,8 +26,8 @@ describe('secret detection report actions', () => { }); describe('setDiffEndpoint', () => { - it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, (done) => { - testAction( + it(`should commit ${types.SET_DIFF_ENDPOINT} with the correct path`, () => { + return testAction( actions.setDiffEndpoint, diffEndpoint, state, @@ -38,20 +38,19 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('requestDiff', () => { - it(`should commit ${types.REQUEST_DIFF}`, (done) => { - testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], [], done); + it(`should commit ${types.REQUEST_DIFF}`, () => { + return testAction(actions.requestDiff, {}, state, [{ type: types.REQUEST_DIFF }], []); }); }); describe('receiveDiffSuccess', () => { - it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_SUCCESS} with the correct response`, () => { + return testAction( actions.receiveDiffSuccess, reports, state, @@ -62,14 +61,13 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); describe('receiveDiffError', () => { - it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, (done) => { - testAction( + it(`should commit ${types.RECEIVE_DIFF_ERROR} with the correct response`, () => { + return testAction( actions.receiveDiffError, error, state, @@ -80,7 +78,6 @@ describe('secret detection report actions', () => { }, ], [], - done, ); }); }); @@ -107,9 +104,10 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffSuccess` action', (done) => { + it('should dispatch the `receiveDiffSuccess` action', () => { const { diff, enrichData } = reports; - testAction( + + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -124,7 +122,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -135,10 +132,10 @@ describe('secret detection report actions', () => { mock.onGet(diffEndpoint).replyOnce(200, reports.diff); }); - it('should dispatch the `receiveDiffSuccess` action with empty enrich data', (done) => { + it('should dispatch the `receiveDiffSuccess` action with empty enrich data', () => { const { diff } = reports; const enrichData = []; - testAction( + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, @@ -153,7 +150,6 @@ describe('secret detection report actions', () => { }, }, ], - done, ); }); }); @@ -167,14 +163,13 @@ describe('secret detection report actions', () => { .replyOnce(404); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); @@ -188,14 +183,13 @@ describe('secret detection report actions', () => { .replyOnce(200, reports.enrichData); }); - it('should dispatch the `receiveDiffError` action', (done) => { - testAction( + it('should dispatch the `receiveDiffError` action', () => { + return testAction( actions.fetchDiff, {}, { ...rootState, ...state }, [], [{ type: 'requestDiff' }, { type: 'receiveDiffError' }], - done, ); }); }); diff --git a/spec/frontend/vuex_shared/modules/modal/actions_spec.js b/spec/frontend/vuex_shared/modules/modal/actions_spec.js index c151049df2d..928ed7d0d5f 100644 --- a/spec/frontend/vuex_shared/modules/modal/actions_spec.js +++ b/spec/frontend/vuex_shared/modules/modal/actions_spec.js @@ -4,28 +4,28 @@ import * as types from '~/vuex_shared/modules/modal/mutation_types'; describe('Vuex ModalModule actions', () => { describe('open', () => { - it('works', (done) => { + it('works', () => { const data = { id: 7 }; - testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], [], done); + return testAction(actions.open, data, {}, [{ type: types.OPEN, payload: data }], []); }); }); describe('close', () => { - it('works', (done) => { - testAction(actions.close, null, {}, [{ type: types.CLOSE }], [], done); + it('works', () => { + return testAction(actions.close, null, {}, [{ type: types.CLOSE }], []); }); }); describe('show', () => { - it('works', (done) => { - testAction(actions.show, null, {}, [{ type: types.SHOW }], [], done); + it('works', () => { + return testAction(actions.show, null, {}, [{ type: types.SHOW }], []); }); }); describe('hide', () => { - it('works', (done) => { - testAction(actions.hide, null, {}, [{ type: types.HIDE }], [], done); + it('works', () => { + return testAction(actions.hide, null, {}, [{ type: types.HIDE }], []); }); }); }); diff --git a/spec/frontend/work_items/components/item_title_spec.js b/spec/frontend/work_items/components/item_title_spec.js index 0f6e7091c59..0d85df25b4f 100644 --- a/spec/frontend/work_items/components/item_title_spec.js +++ b/spec/frontend/work_items/components/item_title_spec.js @@ -4,10 +4,10 @@ import ItemTitle from '~/work_items/components/item_title.vue'; jest.mock('lodash/escape', () => jest.fn((fn) => fn)); -const createComponent = ({ initialTitle = 'Sample title', disabled = false } = {}) => +const createComponent = ({ title = 'Sample title', disabled = false } = {}) => shallowMount(ItemTitle, { propsData: { - initialTitle, + title, disabled, }, }); diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js new file mode 100644 index 00000000000..d0e9cfee353 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_actions_spec.js @@ -0,0 +1,103 @@ +import { GlDropdownItem, GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import waitForPromises from 'helpers/wait_for_promises'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; +import deleteWorkItem from '~/work_items/graphql/delete_work_item.mutation.graphql'; +import { deleteWorkItemResponse, deleteWorkItemFailureResponse } from '../mock_data'; + +describe('WorkItemActions component', () => { + let wrapper; + let glModalDirective; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findDeleteButton = () => wrapper.findComponent(GlDropdownItem); + + const createComponent = ({ + canUpdate = true, + deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse), + } = {}) => { + glModalDirective = jest.fn(); + wrapper = shallowMount(WorkItemActions, { + apolloProvider: createMockApollo([[deleteWorkItem, deleteWorkItemHandler]]), + propsData: { workItemId: '123', canUpdate }, + directives: { + glModal: { + bind(_, { value }) { + glModalDirective(value); + }, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal', () => { + createComponent(); + + expect(findModal().exists()).toBe(true); + expect(findModal().props('visible')).toBe(false); + }); + + it('shows confirm modal when clicking Delete work item', () => { + createComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(glModalDirective).toHaveBeenCalled(); + }); + + it('calls delete mutation when clicking OK button', () => { + const deleteWorkItemHandler = jest.fn().mockResolvedValue(deleteWorkItemResponse); + + createComponent({ + deleteWorkItemHandler, + }); + + findModal().vm.$emit('ok'); + + expect(deleteWorkItemHandler).toHaveBeenCalled(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits event after delete success', async () => { + createComponent(); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('workItemDeleted')).not.toBeUndefined(); + expect(wrapper.emitted('error')).toBeUndefined(); + }); + + it('emits error event after delete failure', async () => { + createComponent({ + deleteWorkItemHandler: jest.fn().mockResolvedValue(deleteWorkItemFailureResponse), + }); + + findModal().vm.$emit('ok'); + + await waitForPromises(); + + expect(wrapper.emitted('error')[0]).toEqual([ + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + ]); + expect(wrapper.emitted('workItemDeleted')).toBeUndefined(); + }); + + it('does not render when canUpdate is false', () => { + createComponent({ + canUpdate: false, + }); + + expect(wrapper.html()).toBe(''); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js new file mode 100644 index 00000000000..9f35ccb853b --- /dev/null +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -0,0 +1,58 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; +import WorkItemActions from '~/work_items/components/work_item_actions.vue'; + +describe('WorkItemDetailModal component', () => { + let wrapper; + + Vue.use(VueApollo); + + const findModal = () => wrapper.findComponent(GlModal); + const findWorkItemActions = () => wrapper.findComponent(WorkItemActions); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + + const createComponent = ({ visible = true, workItemId = '1', canUpdate = false } = {}) => { + wrapper = shallowMount(WorkItemDetailModal, { + propsData: { visible, workItemId, canUpdate }, + stubs: { + GlModal, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe.each([true, false])('when visible=%s', (visible) => { + it(`${visible ? 'renders' : 'does not render'} modal`, () => { + createComponent({ visible }); + + expect(findModal().props('visible')).toBe(visible); + }); + }); + + it('renders heading', () => { + createComponent(); + + expect(wrapper.find('h2').text()).toBe('Work Item'); + }); + + it('renders WorkItemDetail', () => { + createComponent(); + + expect(findWorkItemDetail().props()).toEqual({ workItemId: '1' }); + }); + + it('shows work item actions', () => { + createComponent({ + canUpdate: true, + }); + + expect(findWorkItemActions().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js deleted file mode 100644 index 305f43ad8ba..00000000000 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import WorkItemTitle from '~/work_items/components/item_title.vue'; -import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; - -describe('WorkItemDetailModal component', () => { - let wrapper; - - Vue.use(VueApollo); - - const findModal = () => wrapper.findComponent(GlModal); - const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); - - const createComponent = () => { - wrapper = shallowMount(WorkItemDetailModal, { - apolloProvider: createMockApollo([], resolvers), - propsData: { visible: true }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders modal', () => { - createComponent(); - - expect(findModal().props()).toMatchObject({ visible: true }); - }); - - it('renders work item title', () => { - createComponent(); - - expect(findWorkItemTitle().exists()).toBe(true); - }); -}); diff --git a/spec/frontend/work_items/components/work_item_title_spec.js b/spec/frontend/work_items/components/work_item_title_spec.js new file mode 100644 index 00000000000..9b1ef2d14e4 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_title_spec.js @@ -0,0 +1,117 @@ +import { GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { mockTracking } from 'helpers/tracking_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import ItemTitle from '~/work_items/components/item_title.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import { i18n } from '~/work_items/constants'; +import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { updateWorkItemMutationResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemTitle component', () => { + let wrapper; + + Vue.use(VueApollo); + + const mutationSuccessHandler = jest.fn().mockResolvedValue(updateWorkItemMutationResponse); + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findItemTitle = () => wrapper.findComponent(ItemTitle); + + const createComponent = ({ loading = false, mutationHandler = mutationSuccessHandler } = {}) => { + const { id, title, workItemType } = workItemQueryResponse.data.workItem; + wrapper = shallowMount(WorkItemTitle, { + apolloProvider: createMockApollo([[updateWorkItemMutation, mutationHandler]]), + propsData: { + loading, + workItemId: id, + workItemTitle: title, + workItemType: workItemType.name, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ loading: true }); + }); + + it('renders loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(true); + }); + + it('does not render title', () => { + expect(findItemTitle().exists()).toBe(false); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent({ loading: false }); + }); + + it('does not render loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('renders title', () => { + expect(findItemTitle().props('title')).toBe(workItemQueryResponse.data.workItem.title); + }); + }); + + describe('when updating the title', () => { + it('calls a mutation', () => { + const title = 'new title!'; + + createComponent(); + + findItemTitle().vm.$emit('title-changed', title); + + expect(mutationSuccessHandler).toHaveBeenCalledWith({ + input: { + id: workItemQueryResponse.data.workItem.id, + title, + }, + }); + }); + + it('does not call a mutation when the title has not changed', () => { + createComponent(); + + findItemTitle().vm.$emit('title-changed', workItemQueryResponse.data.workItem.title); + + expect(mutationSuccessHandler).not.toHaveBeenCalled(); + }); + + it('emits an error message when the mutation was unsuccessful', async () => { + createComponent({ mutationHandler: jest.fn().mockRejectedValue('Error!') }); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(wrapper.emitted('error')).toEqual([[i18n.updateError]]); + }); + + it('tracks editing the title', async () => { + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + createComponent(); + + findItemTitle().vm.$emit('title-changed', 'new title'); + await waitForPromises(); + + expect(trackingSpy).toHaveBeenCalledWith('workItems:show', 'updated_title', { + category: 'workItems:show', + label: 'item_title', + property: 'type_Task', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 832795fc4ac..722e1708c15 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -1,21 +1,14 @@ export const workItemQueryResponse = { - workItem: { - __typename: 'WorkItem', - id: '1', - title: 'Test', - workItemType: { - __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - contentText: 'Test', - }, - ], + data: { + workItem: { + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', + title: 'Test', + workItemType: { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + }, }, }, }; @@ -23,25 +16,15 @@ export const workItemQueryResponse = { export const updateWorkItemMutationResponse = { data: { workItemUpdate: { - __typename: 'LocalUpdateWorkItemPayload', + __typename: 'WorkItemUpdatePayload', workItem: { - __typename: 'LocalWorkItem', - id: '1', + __typename: 'WorkItem', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', - }, - widgets: { - __typename: 'LocalWorkItemWidgetConnection', - nodes: [ - { - __typename: 'LocalTitleWidget', - type: 'TITLE', - enabled: true, - contentText: 'Updated title', - }, - ], + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, @@ -51,11 +34,11 @@ export const updateWorkItemMutationResponse = { export const projectWorkItemTypesQueryResponse = { data: { workspace: { - id: '1', + id: 'gid://gitlab/WorkItem/1', workItemTypes: { nodes: [ - { id: 'work-item-1', name: 'Issue' }, - { id: 'work-item-2', name: 'Incident' }, + { id: 'gid://gitlab/WorkItems::Type/1', name: 'Issue' }, + { id: 'gid://gitlab/WorkItems::Type/2', name: 'Incident' }, ], }, }, @@ -68,13 +51,53 @@ export const createWorkItemMutationResponse = { __typename: 'WorkItemCreatePayload', workItem: { __typename: 'WorkItem', - id: '1', + id: 'gid://gitlab/WorkItem/1', title: 'Updated title', workItemType: { __typename: 'WorkItemType', - id: 'work-item-type-1', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', }, }, }, }, }; + +export const createWorkItemFromTaskMutationResponse = { + data: { + workItemCreateFromTask: { + __typename: 'WorkItemCreateFromTaskPayload', + errors: [], + workItem: { + descriptionHtml: '

      New description

      ', + id: 'gid://gitlab/WorkItem/13', + __typename: 'WorkItem', + }, + }, + }, +}; + +export const deleteWorkItemResponse = { + data: { workItemDelete: { errors: [], __typename: 'WorkItemDeletePayload' } }, +}; + +export const deleteWorkItemFailureResponse = { + data: { workItemDelete: null }, + errors: [ + { + message: + "The resource that you are attempting to access does not exist or you don't have permission to perform this action", + locations: [{ line: 2, column: 3 }], + path: ['workItemDelete'], + }, + ], +}; + +export const workItemTitleSubscriptionResponse = { + data: { + issuableTitleUpdated: { + id: 'gid://gitlab/WorkItem/1', + title: 'new title', + }, + }, +}; diff --git a/spec/frontend/work_items/pages/create_work_item_spec.js b/spec/frontend/work_items/pages/create_work_item_spec.js index 185b05c5191..fb1f1d56356 100644 --- a/spec/frontend/work_items/pages/create_work_item_spec.js +++ b/spec/frontend/work_items/pages/create_work_item_spec.js @@ -1,15 +1,19 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlFormSelect } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; -import { projectWorkItemTypesQueryResponse, createWorkItemMutationResponse } from '../mock_data'; +import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_item_from_task.mutation.graphql'; +import { + projectWorkItemTypesQueryResponse, + createWorkItemMutationResponse, + createWorkItemFromTaskMutationResponse, +} from '../mock_data'; jest.mock('~/lib/utils/uuids', () => ({ uuids: () => ['testuuid'] })); @@ -20,12 +24,15 @@ describe('Create work item component', () => { let fakeApollo; const querySuccessHandler = jest.fn().mockResolvedValue(projectWorkItemTypesQueryResponse); - const mutationSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemSuccessHandler = jest.fn().mockResolvedValue(createWorkItemMutationResponse); + const createWorkItemFromTaskSuccessHandler = jest + .fn() + .mockResolvedValue(createWorkItemFromTaskMutationResponse); + const errorHandler = jest.fn().mockRejectedValue('Houston, we have a problem'); const findAlert = () => wrapper.findComponent(GlAlert); const findTitleInput = () => wrapper.findComponent(ItemTitle); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + const findSelect = () => wrapper.findComponent(GlFormSelect); const findCreateButton = () => wrapper.find('[data-testid="create-button"]'); const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]'); @@ -36,15 +43,13 @@ describe('Create work item component', () => { data = {}, props = {}, queryHandler = querySuccessHandler, - mutationHandler = mutationSuccessHandler, + mutationHandler = createWorkItemSuccessHandler, } = {}) => { - fakeApollo = createMockApollo( - [ - [projectWorkItemTypesQuery, queryHandler], - [createWorkItemMutation, mutationHandler], - ], - resolvers, - ); + fakeApollo = createMockApollo([ + [projectWorkItemTypesQuery, queryHandler], + [createWorkItemMutation, mutationHandler], + [createWorkItemFromTaskMutation, mutationHandler], + ]); wrapper = shallowMount(CreateWorkItem, { apolloProvider: fakeApollo, data() { @@ -123,6 +128,7 @@ describe('Create work item component', () => { props: { isModal: true, }, + mutationHandler: createWorkItemFromTaskSuccessHandler, }); }); @@ -133,14 +139,12 @@ describe('Create work item component', () => { }); it('emits `onCreate` on successful mutation', async () => { - const mockTitle = 'Test title'; findTitleInput().vm.$emit('title-input', 'Test title'); wrapper.find('form').trigger('submit'); await waitForPromises(); - const expected = { id: '1', title: mockTitle }; - expect(wrapper.emitted('onCreate')).toEqual([[expected]]); + expect(wrapper.emitted('onCreate')).toEqual([['

      New description

      ']]); }); it('does not right margin for create button', () => { @@ -177,16 +181,14 @@ describe('Create work item component', () => { }); it('displays a list of work item types', () => { - expect(findDropdownItems()).toHaveLength(2); - expect(findDropdownItems().at(0).text()).toContain('Issue'); + expect(findSelect().attributes('options').split(',')).toHaveLength(3); }); it('selects a work item type on click', async () => { - expect(findDropdown().props('text')).toBe('Type'); - findDropdownItems().at(0).vm.$emit('click'); + const mockId = 'work-item-1'; + findSelect().vm.$emit('input', mockId); await nextTick(); - - expect(findDropdown().props('text')).toBe('Issue'); + expect(findSelect().attributes('value')).toBe(mockId); }); }); @@ -206,21 +208,36 @@ describe('Create work item component', () => { createComponent({ props: { initialTitle }, }); - expect(findTitleInput().props('initialTitle')).toBe(initialTitle); + expect(findTitleInput().props('title')).toBe(initialTitle); }); describe('when title input field has a text', () => { - beforeEach(() => { + beforeEach(async () => { const mockTitle = 'Test title'; createComponent(); + await waitForPromises(); findTitleInput().vm.$emit('title-input', mockTitle); }); - it('renders a non-disabled Create button', () => { + it('renders a disabled Create button', () => { + expect(findCreateButton().props('disabled')).toBe(true); + }); + + it('renders a non-disabled Create button when work item type is selected', async () => { + findSelect().vm.$emit('input', 'work-item-1'); + await nextTick(); expect(findCreateButton().props('disabled')).toBe(false); }); + }); + + it('shows an alert on mutation error', async () => { + createComponent({ mutationHandler: errorHandler }); + await waitForPromises(); + findTitleInput().vm.$emit('title-input', 'some title'); + findSelect().vm.$emit('input', 'work-item-1'); + wrapper.find('form').trigger('submit'); + await waitForPromises(); - // TODO: write a proper test here when we have a backend implementation - it.todo('shows an alert on mutation error'); + expect(findAlert().text()).toBe(CreateWorkItem.createErrorText); }); }); diff --git a/spec/frontend/work_items/pages/work_item_detail_spec.js b/spec/frontend/work_items/pages/work_item_detail_spec.js new file mode 100644 index 00000000000..1eb6c0145e7 --- /dev/null +++ b/spec/frontend/work_items/pages/work_item_detail_spec.js @@ -0,0 +1,99 @@ +import { GlAlert } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; +import WorkItemTitle from '~/work_items/components/work_item_title.vue'; +import { i18n } from '~/work_items/constants'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; +import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql'; +import { workItemTitleSubscriptionResponse, workItemQueryResponse } from '../mock_data'; + +describe('WorkItemDetail component', () => { + let wrapper; + + Vue.use(VueApollo); + + const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle); + + const createComponent = ({ + workItemId = workItemQueryResponse.data.workItem.id, + handler = successHandler, + subscriptionHandler = initialSubscriptionHandler, + } = {}) => { + wrapper = shallowMount(WorkItemDetail, { + apolloProvider: createMockApollo([ + [workItemQuery, handler], + [workItemTitleSubscription, subscriptionHandler], + ]), + propsData: { workItemId }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there is no `workItemId` prop', () => { + beforeEach(() => { + createComponent({ workItemId: null }); + }); + + it('skips the work item query', () => { + expect(successHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(true); + }); + }); + + describe('when loaded', () => { + beforeEach(() => { + createComponent(); + return waitForPromises(); + }); + + it('does not render WorkItemTitle in loading state', () => { + expect(findWorkItemTitle().props('loading')).toBe(false); + }); + }); + + it('shows an error message when the work item query was unsuccessful', async () => { + const errorHandler = jest.fn().mockRejectedValue('Oops'); + createComponent({ handler: errorHandler }); + await waitForPromises(); + + expect(errorHandler).toHaveBeenCalled(); + expect(findAlert().text()).toBe(i18n.fetchError); + }); + + it('shows an error message when WorkItemTitle emits an `error` event', async () => { + createComponent(); + + findWorkItemTitle().vm.$emit('error', i18n.updateError); + await waitForPromises(); + + expect(findAlert().text()).toBe(i18n.updateError); + }); + + it('calls the subscription', () => { + createComponent(); + + expect(initialSubscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, + }); + }); +}); diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 728495e0e23..2803724b9af 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -1,108 +1,31 @@ -import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; -import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; -import ItemTitle from '~/work_items/components/item_title.vue'; -import { resolvers } from '~/work_items/graphql/resolvers'; -import { workItemQueryResponse, updateWorkItemMutationResponse } from '../mock_data'; Vue.use(VueApollo); -const WORK_ITEM_ID = '1'; -const WORK_ITEM_GID = `gid://gitlab/WorkItem/${WORK_ITEM_ID}`; - describe('Work items root component', () => { - const mockUpdatedTitle = 'Updated title'; let wrapper; - let fakeApollo; - - const findTitle = () => wrapper.findComponent(ItemTitle); - const createComponent = ({ queryResponse = workItemQueryResponse } = {}) => { - fakeApollo = createMockApollo( - [[updateWorkItemMutation, jest.fn().mockResolvedValue(updateWorkItemMutationResponse)]], - resolvers, - { - possibleTypes: { - LocalWorkItemWidget: ['LocalTitleWidget'], - }, - }, - ); - fakeApollo.clients.defaultClient.cache.writeQuery({ - query: workItemQuery, - variables: { - id: WORK_ITEM_GID, - }, - data: queryResponse, - }); + const findWorkItemDetail = () => wrapper.findComponent(WorkItemDetail); + const createComponent = () => { wrapper = shallowMount(WorkItemsRoot, { propsData: { - id: WORK_ITEM_ID, + id: '1', }, - apolloProvider: fakeApollo, }); }; afterEach(() => { wrapper.destroy(); - fakeApollo = null; }); - it('renders the title', () => { + it('renders WorkItemDetail', () => { createComponent(); - expect(findTitle().exists()).toBe(true); - expect(findTitle().props('initialTitle')).toBe('Test'); - }); - - it('updates the title when it is edited', async () => { - createComponent(); - jest.spyOn(wrapper.vm.$apollo, 'mutate'); - - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateWorkItemMutation, - variables: { - input: { - id: WORK_ITEM_GID, - title: mockUpdatedTitle, - }, - }, - }); - }); - - describe('tracking', () => { - let trackingSpy; - - beforeEach(() => { - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - - createComponent(); - }); - - afterEach(() => { - unmockTracking(); - }); - - it('tracks item title updates', async () => { - await findTitle().vm.$emit('title-changed', mockUpdatedTitle); - - await waitForPromises(); - - expect(trackingSpy).toHaveBeenCalledTimes(1); - expect(trackingSpy).toHaveBeenCalledWith('workItems:show', undefined, { - action: 'updated_title', - category: 'workItems:show', - label: 'item_title', - property: '[type_work_item]', - }); - }); + expect(findWorkItemDetail().props()).toEqual({ workItemId: 'gid://gitlab/WorkItem/1' }); }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 8c9054920a8..7e68c5e4f0e 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -37,7 +37,7 @@ describe('Work items router', () => { it('renders work item on `/1` route', async () => { await createComponent('/1'); - expect(wrapper.find(WorkItemsRoot).exists()).toBe(true); + expect(wrapper.findComponent(WorkItemsRoot).exists()).toBe(true); }); it('renders create work item page on `/new` route', async () => { diff --git a/spec/frontend_integration/content_editor/content_editor_integration_spec.js b/spec/frontend_integration/content_editor/content_editor_integration_spec.js new file mode 100644 index 00000000000..1b45c0d43a3 --- /dev/null +++ b/spec/frontend_integration/content_editor/content_editor_integration_spec.js @@ -0,0 +1,63 @@ +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { ContentEditor } from '~/content_editor'; + +/** + * This spec exercises some workflows in the Content Editor without mocking + * any component. + * + */ +describe('content_editor', () => { + let wrapper; + let renderMarkdown; + let contentEditorService; + + const buildWrapper = () => { + renderMarkdown = jest.fn(); + wrapper = mountExtended(ContentEditor, { + propsData: { + renderMarkdown, + uploadsPath: '/', + }, + listeners: { + initialized(contentEditor) { + contentEditorService = contentEditor; + }, + }, + }); + }; + + describe('when loading initial content', () => { + describe('when the initial content is empty', () => { + it('still hides the loading indicator', async () => { + buildWrapper(); + + renderMarkdown.mockResolvedValue(''); + + await contentEditorService.setSerializedContent(''); + await nextTick(); + + expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false); + }); + }); + + describe('when the initial content is not empty', () => { + const initialContent = '

      bold text

      '; + beforeEach(async () => { + buildWrapper(); + + renderMarkdown.mockResolvedValue(initialContent); + + await contentEditorService.setSerializedContent('**bold text**'); + await nextTick(); + }); + it('hides the loading indicator', async () => { + expect(wrapper.findByTestId('content-editor-loading-indicator').exists()).toBe(false); + }); + + it('displays the initial content', async () => { + expect(wrapper.html()).toContain(initialContent); + }); + }); + }); +}); diff --git a/spec/graphql/graphql_triggers_spec.rb b/spec/graphql/graphql_triggers_spec.rb index 2d83edca363..84af33a5cb3 100644 --- a/spec/graphql/graphql_triggers_spec.rb +++ b/spec/graphql/graphql_triggers_spec.rb @@ -31,4 +31,20 @@ RSpec.describe GraphqlTriggers do GraphqlTriggers.issuable_title_updated(work_item) end end + + describe '.issuable_labels_updated' do + it 'triggers the issuableLabelsUpdated subscription' do + project = create(:project) + labels = create_list(:label, 3, project: project) + issue = create(:issue, labels: labels) + + expect(GitlabSchema.subscriptions).to receive(:trigger).with( + 'issuableLabelsUpdated', + { issuable_id: issue.to_gid }, + issue + ) + + GraphqlTriggers.issuable_labels_updated(issue) + end + end end diff --git a/spec/graphql/mutations/ci/runner/delete_spec.rb b/spec/graphql/mutations/ci/runner/delete_spec.rb index c0f979e43cc..ee640b21918 100644 --- a/spec/graphql/mutations/ci/runner/delete_spec.rb +++ b/spec/graphql/mutations/ci/runner/delete_spec.rb @@ -37,7 +37,9 @@ RSpec.describe Mutations::Ci::Runner::Delete do it 'raises an error' do mutation_params[:id] = two_projects_runner.to_global_id - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end @@ -115,7 +117,10 @@ RSpec.describe Mutations::Ci::Runner::Delete do allow_next_instance_of(::Ci::Runners::UnregisterRunnerService) do |service| expect(service).not_to receive(:execute) end - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end diff --git a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb index 48e55828a6b..fdf9cbaf25b 100644 --- a/spec/graphql/mutations/environments/canary_ingress/update_spec.rb +++ b/spec/graphql/mutations/environments/canary_ingress/update_spec.rb @@ -36,6 +36,20 @@ RSpec.describe Mutations::Environments::CanaryIngress::Update do it 'returns no errors' do expect(subject[:errors]).to be_empty end + + context 'with certificate_based_clusters disabled' do + before do + stub_feature_flags(certificate_based_clusters: false) + end + + it 'returns notice about feature removal' do + expect(subject[:errors]).to match_array([ + 'This endpoint was deactivated as part of the certificate-based' \ + 'kubernetes integration removal. See Epic:' \ + 'https://gitlab.com/groups/gitlab-org/configure/-/epics/8' + ]) + end + end end context 'when service encounters a problem' do diff --git a/spec/graphql/mutations/saved_replies/destroy_spec.rb b/spec/graphql/mutations/saved_replies/destroy_spec.rb new file mode 100644 index 00000000000..6cff28ec0b2 --- /dev/null +++ b/spec/graphql/mutations/saved_replies/destroy_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::SavedReplies::Destroy do + let_it_be(:current_user) { create(:user) } + let_it_be(:saved_reply) { create(:saved_reply, user: current_user) } + + let(:mutation) { described_class.new(object: nil, context: { current_user: current_user }, field: nil) } + + describe '#resolve' do + subject(:resolve) do + mutation.resolve(id: saved_reply.to_global_id) + end + + context 'when feature is disabled' do + before do + stub_feature_flags(saved_replies: false) + end + + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled') + end + end + + context 'when feature is enabled for current user' do + before do + stub_feature_flags(saved_replies: current_user) + end + + context 'when service fails to delete a new saved reply' do + before do + saved_reply.destroy! + end + + it 'raises Gitlab::Graphql::Errors::ResourceNotAvailable' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when service successfully deletes the saved reply' do + it { expect(subject[:errors]).to be_empty } + end + end + end +end diff --git a/spec/graphql/resolvers/blobs_resolver_spec.rb b/spec/graphql/resolvers/blobs_resolver_spec.rb index 4b75351147c..a666ed2a9fc 100644 --- a/spec/graphql/resolvers/blobs_resolver_spec.rb +++ b/spec/graphql/resolvers/blobs_resolver_spec.rb @@ -75,10 +75,9 @@ RSpec.describe Resolvers::BlobsResolver do let(:ref) { 'ma:in' } it 'raises an ArgumentError' do - expect { resolve_blobs }.to raise_error( - Gitlab::Graphql::Errors::ArgumentError, - 'Ref is not valid' - ) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do + resolve_blobs + end end end @@ -86,10 +85,9 @@ RSpec.describe Resolvers::BlobsResolver do let(:ref) { '' } it 'raises an ArgumentError' do - expect { resolve_blobs }.to raise_error( - Gitlab::Graphql::Errors::ArgumentError, - 'Ref is not valid' - ) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ArgumentError, 'Ref is not valid') do + resolve_blobs + end end end end diff --git a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb index fcf67120b0e..8d0b8f9398d 100644 --- a/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb +++ b/spec/graphql/resolvers/group_members/notification_email_resolver_spec.rb @@ -35,7 +35,9 @@ RSpec.describe Resolvers::GroupMembers::NotificationEmailResolver do let(:current_user) { create(:user) } it 'raises ResourceNotAvailable error' do - expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + subject + end end end end diff --git a/spec/graphql/resolvers/issues_resolver_spec.rb b/spec/graphql/resolvers/issues_resolver_spec.rb index 5e9a3d0a68b..81aeee0a3d2 100644 --- a/spec/graphql/resolvers/issues_resolver_spec.rb +++ b/spec/graphql/resolvers/issues_resolver_spec.rb @@ -522,11 +522,53 @@ RSpec.describe Resolvers::IssuesResolver do end end + context 'when sorting by escalation status' do + let_it_be(:project) { create(:project, :public) } + let_it_be(:triggered_incident) { create(:incident, :with_escalation_status, project: project) } + let_it_be(:issue_no_status) { create(:issue, project: project) } + let_it_be(:resolved_incident) do + create(:incident, :with_escalation_status, project: project) + .tap { |issue| issue.escalation_status.resolve } + end + + it 'sorts issues ascending' do + issues = resolve_issues(sort: :escalation_status_asc).to_a + expect(issues).to eq([triggered_incident, resolved_incident, issue_no_status]) + end + + it 'sorts issues descending' do + issues = resolve_issues(sort: :escalation_status_desc).to_a + expect(issues).to eq([resolved_incident, triggered_incident, issue_no_status]) + end + + it 'sorts issues created_at' do + issues = resolve_issues(sort: :created_desc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + + context 'when incident_escalations feature flag is disabled' do + before do + stub_feature_flags(incident_escalations: false) + end + + it 'defaults ascending status sort to created_desc' do + issues = resolve_issues(sort: :escalation_status_asc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + + it 'defaults descending status sort to created_desc' do + issues = resolve_issues(sort: :escalation_status_desc).to_a + expect(issues).to eq([resolved_incident, issue_no_status, triggered_incident]) + end + end + end + context 'when sorting with non-stable cursors' do %i[priority_asc priority_desc popularity_asc popularity_desc label_priority_asc label_priority_desc - milestone_due_asc milestone_due_desc].each do |sort_by| + milestone_due_asc milestone_due_desc + escalation_status_asc escalation_status_desc].each do |sort_by| it "uses offset-pagination when sorting by #{sort_by}" do resolved = resolve_issues(sort: sort_by) diff --git a/spec/graphql/resolvers/project_jobs_resolver_spec.rb b/spec/graphql/resolvers/project_jobs_resolver_spec.rb index 94df2999163..bb711a4c857 100644 --- a/spec/graphql/resolvers/project_jobs_resolver_spec.rb +++ b/spec/graphql/resolvers/project_jobs_resolver_spec.rb @@ -9,9 +9,10 @@ RSpec.describe Resolvers::ProjectJobsResolver do let_it_be(:irrelevant_project) { create(:project, :repository) } let_it_be(:pipeline) { create(:ci_pipeline, project: project) } let_it_be(:irrelevant_pipeline) { create(:ci_pipeline, project: irrelevant_project) } - let_it_be(:build_one) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) } - let_it_be(:build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) } - let_it_be(:build_three) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) } + let_it_be(:successful_build) { create(:ci_build, :success, name: 'Build One', pipeline: pipeline) } + let_it_be(:successful_build_two) { create(:ci_build, :success, name: 'Build Two', pipeline: pipeline) } + let_it_be(:failed_build) { create(:ci_build, :failed, name: 'Build Three', pipeline: pipeline) } + let_it_be(:pending_build) { create(:ci_build, :pending, name: 'Build Three', pipeline: pipeline) } let(:irrelevant_build) { create(:ci_build, name: 'Irrelevant Build', pipeline: irrelevant_pipeline)} let(:args) { {} } @@ -28,11 +29,17 @@ RSpec.describe Resolvers::ProjectJobsResolver do context 'with statuses argument' do let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS')] } } - it { is_expected.to contain_exactly(build_one, build_two) } + it { is_expected.to contain_exactly(successful_build, successful_build_two) } + end + + context 'with multiple statuses' do + let(:args) { { statuses: [Types::Ci::JobStatusEnum.coerce_isolated_input('SUCCESS'), Types::Ci::JobStatusEnum.coerce_isolated_input('FAILED')] } } + + it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build) } end context 'without statuses argument' do - it { is_expected.to contain_exactly(build_one, build_two, build_three) } + it { is_expected.to contain_exactly(successful_build, successful_build_two, failed_build, pending_build) } end end diff --git a/spec/graphql/resolvers/users_resolver_spec.rb b/spec/graphql/resolvers/users_resolver_spec.rb index b01cc0d43e3..1ba296912a3 100644 --- a/spec/graphql/resolvers/users_resolver_spec.rb +++ b/spec/graphql/resolvers/users_resolver_spec.rb @@ -74,7 +74,9 @@ RSpec.describe Resolvers::UsersResolver do let_it_be(:current_user) { nil } it 'prohibits search without usernames passed' do - expect { resolve_users }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolve_users + end end it 'allows to search by username' do diff --git a/spec/graphql/resolvers/work_item_resolver_spec.rb b/spec/graphql/resolvers/work_item_resolver_spec.rb index c7e2beecb51..bfa0cf1d8a2 100644 --- a/spec/graphql/resolvers/work_item_resolver_spec.rb +++ b/spec/graphql/resolvers/work_item_resolver_spec.rb @@ -22,7 +22,9 @@ RSpec.describe Resolvers::WorkItemResolver do let(:current_user) { create(:user) } it 'raises a resource not available error' do - expect { resolved_work_item }.to raise_error(::Gitlab::Graphql::Errors::ResourceNotAvailable) + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolved_work_item + end end end diff --git a/spec/graphql/resolvers/work_items/types_resolver_spec.rb b/spec/graphql/resolvers/work_items/types_resolver_spec.rb index f7aeed30fd3..868f4566ad6 100644 --- a/spec/graphql/resolvers/work_items/types_resolver_spec.rb +++ b/spec/graphql/resolvers/work_items/types_resolver_spec.rb @@ -53,5 +53,15 @@ RSpec.describe Resolvers::WorkItems::TypesResolver do it_behaves_like 'a work item type resolver' end + + context 'when parent is not a group or project' do + let(:object) { 'not a project/group' } + + it 'returns nil because of feature flag check' do + result = resolve(described_class, obj: object, args: {}) + + expect(result).to be_nil + end + end end end diff --git a/spec/graphql/types/base_object_spec.rb b/spec/graphql/types/base_object_spec.rb index d8f2ef58ea5..45dc885ecba 100644 --- a/spec/graphql/types/base_object_spec.rb +++ b/spec/graphql/types/base_object_spec.rb @@ -428,5 +428,25 @@ RSpec.describe Types::BaseObject do expect(result.dig('data', 'users', 'nodes')) .to contain_exactly({ 'name' => active_users.first.name }) end + + describe '.authorize' do + let_it_be(:read_only_type) do + Class.new(described_class) do + authorize :read_only + end + end + + let_it_be(:inherited_read_only_type) { Class.new(read_only_type) } + + it 'keeps track of the specified value' do + expect(described_class.authorize).to be_nil + expect(read_only_type.authorize).to match_array [:read_only] + expect(inherited_read_only_type.authorize).to match_array [:read_only] + end + + it 'can not redefine the authorize value' do + expect { read_only_type.authorize(:write_only) }.to raise_error('Cannot redefine authorize') + end + end end end diff --git a/spec/graphql/types/ci/job_kind_enum_spec.rb b/spec/graphql/types/ci/job_kind_enum_spec.rb new file mode 100644 index 00000000000..b48d20b71e2 --- /dev/null +++ b/spec/graphql/types/ci/job_kind_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiJobKind'] do + it 'exposes some job type values' do + expect(described_class.values.keys).to match_array( + (%w[BRIDGE BUILD]) + ) + end +end diff --git a/spec/graphql/types/ci/job_type_spec.rb b/spec/graphql/types/ci/job_type_spec.rb index 47d697ab8b8..655c3636883 100644 --- a/spec/graphql/types/ci/job_type_spec.rb +++ b/spec/graphql/types/ci/job_type_spec.rb @@ -21,6 +21,7 @@ RSpec.describe Types::Ci::JobType do downstreamPipeline finished_at id + kind manual_job name needs diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb index aa770284f89..d94516c6fce 100644 --- a/spec/graphql/types/container_repository_details_type_spec.rb +++ b/spec/graphql/types/container_repository_details_type_spec.rb @@ -3,7 +3,9 @@ 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 size project] + fields = %i[id name path location created_at updated_at expiration_policy_started_at + status tags_count can_delete expiration_policy_cleanup_status tags size + project migration_state] it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') } diff --git a/spec/graphql/types/container_repository_type_spec.rb b/spec/graphql/types/container_repository_type_spec.rb index 87e1c11ce19..9815449dd68 100644 --- a/spec/graphql/types/container_repository_type_spec.rb +++ b/spec/graphql/types/container_repository_type_spec.rb @@ -3,7 +3,9 @@ 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 project] + fields = %i[id name path location created_at updated_at expiration_policy_started_at + status tags_count can_delete expiration_policy_cleanup_status project + migration_state] it { expect(described_class.graphql_name).to eq('ContainerRepository') } diff --git a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb index b251ca63c4f..f688b085b10 100644 --- a/spec/graphql/types/dependency_proxy/manifest_type_spec.rb +++ b/spec/graphql/types/dependency_proxy/manifest_type_spec.rb @@ -5,7 +5,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['DependencyProxyManifest'] do it 'includes dependency proxy manifest fields' do expected_fields = %w[ - id file_name image_name size created_at updated_at digest + id file_name image_name size created_at updated_at digest status ] expect(described_class).to include_graphql_fields(*expected_fields) diff --git a/spec/graphql/types/issue_sort_enum_spec.rb b/spec/graphql/types/issue_sort_enum_spec.rb index 4433709d193..95184477e75 100644 --- a/spec/graphql/types/issue_sort_enum_spec.rb +++ b/spec/graphql/types/issue_sort_enum_spec.rb @@ -9,7 +9,7 @@ RSpec.describe GitlabSchema.types['IssueSort'] do it 'exposes all the existing issue sort values' do expect(described_class.values.keys).to include( - *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC] + *%w[DUE_DATE_ASC DUE_DATE_DESC RELATIVE_POSITION_ASC SEVERITY_ASC SEVERITY_DESC ESCALATION_STATUS_ASC ESCALATION_STATUS_DESC] ) end end diff --git a/spec/graphql/types/range_input_type_spec.rb b/spec/graphql/types/range_input_type_spec.rb index fc9126247fa..dbfcf4a41c7 100644 --- a/spec/graphql/types/range_input_type_spec.rb +++ b/spec/graphql/types/range_input_type_spec.rb @@ -24,7 +24,7 @@ RSpec.describe ::Types::RangeInputType do it 'follows expected subtyping relationships for instances' do context = GraphQL::Query::Context.new( - query: double('query', schema: nil), + query: GraphQL::Query.new(GitlabSchema), values: {}, object: nil ) diff --git a/spec/graphql/types/repository/blob_type_spec.rb b/spec/graphql/types/repository/blob_type_spec.rb index a813ef85e6e..787b5f4a311 100644 --- a/spec/graphql/types/repository/blob_type_spec.rb +++ b/spec/graphql/types/repository/blob_type_spec.rb @@ -34,7 +34,6 @@ RSpec.describe Types::Repository::BlobType do :environment_external_url_for_route_map, :code_navigation_path, :project_blob_path_root, - :code_owners, :simple_viewer, :rich_viewer, :plain_data, @@ -47,6 +46,6 @@ RSpec.describe Types::Repository::BlobType do :ide_fork_and_edit_path, :fork_and_view_path, :language - ) + ).at_least end end diff --git a/spec/graphql/types/subscription_type_spec.rb b/spec/graphql/types/subscription_type_spec.rb index 593795de004..1a2629ed422 100644 --- a/spec/graphql/types/subscription_type_spec.rb +++ b/spec/graphql/types/subscription_type_spec.rb @@ -8,6 +8,7 @@ RSpec.describe GitlabSchema.types['Subscription'] do issuable_assignees_updated issue_crm_contacts_updated issuable_title_updated + issuable_labels_updated ] expect(described_class).to have_graphql_fields(*expected_fields).only diff --git a/spec/haml_lint/linter/documentation_links_spec.rb b/spec/haml_lint/linter/documentation_links_spec.rb index 75002097d69..f2aab4304c1 100644 --- a/spec/haml_lint/linter/documentation_links_spec.rb +++ b/spec/haml_lint/linter/documentation_links_spec.rb @@ -43,6 +43,12 @@ RSpec.describe HamlLint::Linter::DocumentationLinks do let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md'), target: '_blank'" } it { is_expected.to report_lint } + + context 'when haml ends with block definition' do + let(:haml) { "= link_to 'Description', #{link_pattern}('wrong.md') do" } + + it { is_expected.to report_lint } + end end context 'when link with wrong file path is assigned to a variable' do diff --git a/spec/helpers/admin/background_migrations_helper_spec.rb b/spec/helpers/admin/background_migrations_helper_spec.rb index 9c1bb0b9c55..e3639ef778e 100644 --- a/spec/helpers/admin/background_migrations_helper_spec.rb +++ b/spec/helpers/admin/background_migrations_helper_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do describe '#batched_migration_status_badge_variant' do using RSpec::Parameterized::TableSyntax - where(:status, :variant) do + where(:status_name, :variant) do :active | :info :paused | :warning :failed | :danger @@ -16,7 +16,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do subject { helper.batched_migration_status_badge_variant(migration) } with_them do - let(:migration) { build(:batched_background_migration, status: status) } + let(:migration) { build(:batched_background_migration, status_name) } it { is_expected.to eq(variant) } end @@ -25,7 +25,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do describe '#batched_migration_progress' do subject { helper.batched_migration_progress(migration, completed_rows) } - let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:migration) { build(:batched_background_migration, :active, total_tuple_count: 100) } let(:completed_rows) { 25 } it 'returns completion percentage' do @@ -33,7 +33,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do end context 'when migration is finished' do - let(:migration) { build(:batched_background_migration, status: :finished, total_tuple_count: nil) } + let(:migration) { build(:batched_background_migration, :finished, total_tuple_count: nil) } it 'returns 100 percent' do expect(subject).to eq(100) @@ -41,7 +41,7 @@ RSpec.describe Admin::BackgroundMigrationsHelper do end context 'when total_tuple_count is nil' do - let(:migration) { build(:batched_background_migration, status: :active, total_tuple_count: nil) } + let(:migration) { build(:batched_background_migration, :active, total_tuple_count: nil) } it 'returns nil' do expect(subject).to eq(nil) diff --git a/spec/helpers/application_settings_helper_spec.rb b/spec/helpers/application_settings_helper_spec.rb index 26d48bef24e..c93762416f5 100644 --- a/spec/helpers/application_settings_helper_spec.rb +++ b/spec/helpers/application_settings_helper_spec.rb @@ -149,7 +149,7 @@ RSpec.describe ApplicationSettingsHelper do end end - describe '.storage_weights' do + describe '#storage_weights' do let(:application_setting) { build(:application_setting) } before do @@ -158,12 +158,13 @@ RSpec.describe ApplicationSettingsHelper do stub_application_setting(repository_storages_weighted: { 'default' => 100, 'storage_1' => 50, 'storage_2' => nil }) end - it 'returns storages correctly' do - expect(helper.storage_weights).to eq(OpenStruct.new( - default: 100, - storage_1: 50, - storage_2: 0 - )) + it 'returns storage objects with assigned weights' do + expect(helper.storage_weights) + .to have_attributes( + default: 100, + storage_1: 50, + storage_2: 0 + ) end end diff --git a/spec/helpers/boards_helper_spec.rb b/spec/helpers/boards_helper_spec.rb index ec949fde30e..8d5dc3fb4be 100644 --- a/spec/helpers/boards_helper_spec.rb +++ b/spec/helpers/boards_helper_spec.rb @@ -102,6 +102,7 @@ RSpec.describe BoardsHelper do allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, project_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue, project_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(false) + allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(false) end it 'returns a board_lists_path as lists_endpoint' do @@ -129,12 +130,23 @@ RSpec.describe BoardsHelper do it 'returns can_admin_list as false by default' do expect(helper.board_data[:can_admin_list]).to eq('false') end - it 'returns can_admin_list as true when user can admin the board' do + it 'returns can_admin_list as true when user can admin the board lists' do allow(helper).to receive(:can?).with(user, :admin_issue_board_list, project).and_return(true) expect(helper.board_data[:can_admin_list]).to eq('true') end end + + context 'can_admin_board' do + it 'returns can_admin_board as false by default' do + expect(helper.board_data[:can_admin_board]).to eq('false') + end + it 'returns can_admin_board as true when user can admin the board' do + allow(helper).to receive(:can?).with(user, :admin_issue_board, project).and_return(true) + + expect(helper.board_data[:can_admin_board]).to eq('true') + end + end end context 'group board' do @@ -146,6 +158,7 @@ RSpec.describe BoardsHelper do allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, group_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue, group_board).and_return(true) allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(false) + allow(helper).to receive(:can?).with(user, :admin_issue_board, base_group).and_return(false) end it 'returns correct path for base group' do @@ -165,7 +178,7 @@ RSpec.describe BoardsHelper do it 'returns can_admin_list as false by default' do expect(helper.board_data[:can_admin_list]).to eq('false') end - it 'returns can_admin_list as true when user can admin the board' do + it 'returns can_admin_list as true when user can admin the board lists' do allow(helper).to receive(:can?).with(user, :admin_issue_board_list, base_group).and_return(true) expect(helper.board_data[:can_admin_list]).to eq('true') diff --git a/spec/helpers/broadcast_messages_helper_spec.rb b/spec/helpers/broadcast_messages_helper_spec.rb index e721a3fdc95..d4021a2eb59 100644 --- a/spec/helpers/broadcast_messages_helper_spec.rb +++ b/spec/helpers/broadcast_messages_helper_spec.rb @@ -115,37 +115,8 @@ RSpec.describe BroadcastMessagesHelper do end it 'includes the current message' do - allow(helper).to receive(:broadcast_message_style).and_return(nil) - expect(helper.broadcast_message(current_broadcast_message)).to include 'Current Message' end - - it 'includes custom style' do - allow(helper).to receive(:broadcast_message_style).and_return('foo') - - expect(helper.broadcast_message(current_broadcast_message)).to include 'style="foo"' - end - end - - describe 'broadcast_message_style' do - it 'defaults to no style' do - broadcast_message = spy - - expect(helper.broadcast_message_style(broadcast_message)).to eq '' - end - - it 'allows custom style for banner messages' do - broadcast_message = BroadcastMessage.new(color: '#f2dede', font: '#b94a48', broadcast_type: "banner") - - expect(helper.broadcast_message_style(broadcast_message)) - .to match('background-color: #f2dede; color: #b94a48') - end - - it 'does not add style for notification messages' do - broadcast_message = BroadcastMessage.new(color: '#f2dede', broadcast_type: "notification") - - expect(helper.broadcast_message_style(broadcast_message)).to eq '' - end end describe 'broadcast_message_status' do diff --git a/spec/helpers/button_helper_spec.rb b/spec/helpers/button_helper_spec.rb index 851e13d908f..a7f65aa3134 100644 --- a/spec/helpers/button_helper_spec.rb +++ b/spec/helpers/button_helper_spec.rb @@ -164,7 +164,7 @@ RSpec.describe ButtonHelper do context 'with default options' do context 'when no `text` attribute is not provided' do it 'shows copy to clipboard button with default configuration and no text set to copy' do - expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent') + expect(element.attr('class')).to eq('btn btn-clipboard gl-button btn-default-tertiary btn-icon btn-sm') expect(element.attr('type')).to eq('button') expect(element.attr('aria-label')).to eq('Copy') expect(element.attr('aria-live')).to eq('polite') diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index b844cc2e22b..12456deb538 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -45,8 +45,8 @@ RSpec.describe Ci::PipelineEditorHelper do "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, - "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), - "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), + "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => graphql_etag_pipeline_sha_path(project.commit.sha), @@ -72,8 +72,8 @@ RSpec.describe Ci::PipelineEditorHelper do "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'foo', "initial-branch-name" => nil, - "lint-help-page-path" => help_page_path('ci/lint', anchor: 'validate-basic-logic-and-syntax'), - "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available'), + "lint-help-page-path" => help_page_path('ci/lint', anchor: 'check-cicd-syntax'), + "lint-unavailable-help-page-path" => help_page_path('ci/pipeline_editor/index', anchor: 'configuration-validation-currently-not-available-message'), "needs-help-page-path" => help_page_path('ci/yaml/index', anchor: 'needs'), "new-merge-request-path" => '/mock/project/-/merge_requests/new', "pipeline_etag" => '', diff --git a/spec/helpers/ci/pipelines_helper_spec.rb b/spec/helpers/ci/pipelines_helper_spec.rb index 2b76eaa87bc..c473e1e4ab6 100644 --- a/spec/helpers/ci/pipelines_helper_spec.rb +++ b/spec/helpers/ci/pipelines_helper_spec.rb @@ -151,5 +151,46 @@ RSpec.describe Ci::PipelinesHelper do end end end + + describe 'the `registration_token` attribute' do + subject { data[:registration_token] } + + describe 'when the project is eligible for the `ios_specific_templates` experiment' do + let_it_be(:project) { create(:project, :auto_devops_disabled) } + let_it_be(:user) { create(:user) } + + before do + allow(helper).to receive(:current_user).and_return(user) + project.add_developer(user) + create(:project_setting, project: project, target_platforms: %w(ios)) + end + + context 'when the `ios_specific_templates` experiment variant is control' do + before do + stub_experiments(ios_specific_templates: :control) + end + + it { is_expected.to be_nil } + end + + context 'when the `ios_specific_templates` experiment variant is candidate' do + before do + stub_experiments(ios_specific_templates: :candidate) + end + + context 'when the user cannot register project runners' do + before do + allow(helper).to receive(:can?).with(user, :register_project_runners, project).and_return(false) + end + + it { is_expected.to be_nil } + end + + context 'when the user can register project runners' do + it { is_expected.to eq(project.runners_token) } + end + end + end + end end end diff --git a/spec/helpers/ci/runners_helper_spec.rb b/spec/helpers/ci/runners_helper_spec.rb index 832b4da0e20..0046d481282 100644 --- a/spec/helpers/ci/runners_helper_spec.rb +++ b/spec/helpers/ci/runners_helper_spec.rb @@ -10,24 +10,31 @@ RSpec.describe Ci::RunnersHelper do end describe '#runner_status_icon', :clean_gitlab_redis_cache do - it "returns - not contacted yet" do + it "returns online text" do + runner = create(:ci_runner, contacted_at: 1.second.ago) + expect(helper.runner_status_icon(runner)).to include("is online") + end + + it "returns never contacted" do runner = create(:ci_runner) - expect(helper.runner_status_icon(runner)).to include("not contacted yet") + expect(helper.runner_status_icon(runner)).to include("never contacted") end it "returns offline text" do - runner = create(:ci_runner, contacted_at: 1.day.ago, active: true) - expect(helper.runner_status_icon(runner)).to include("Runner is offline") + runner = create(:ci_runner, contacted_at: 1.day.ago) + expect(helper.runner_status_icon(runner)).to include("is offline") end - it "returns online text" do - runner = create(:ci_runner, contacted_at: 1.second.ago, active: true) - expect(helper.runner_status_icon(runner)).to include("Runner is online") + it "returns stale text" do + runner = create(:ci_runner, created_at: 4.months.ago, contacted_at: 4.months.ago) + expect(helper.runner_status_icon(runner)).to include("is stale") + expect(helper.runner_status_icon(runner)).to include("last contact was") end - it "returns paused text" do - runner = create(:ci_runner, contacted_at: 1.second.ago, active: false) - expect(helper.runner_status_icon(runner)).to include("Runner is paused") + it "returns stale text, when runner never contacted" do + runner = create(:ci_runner, created_at: 4.months.ago) + expect(helper.runner_status_icon(runner)).to include("is stale") + expect(helper.runner_status_icon(runner)).to include("never contacted") end end @@ -79,7 +86,9 @@ RSpec.describe Ci::RunnersHelper do it 'returns the data in format' do expect(helper.admin_runners_data_attributes).to eq({ runner_install_help_page: 'https://docs.gitlab.com/runner/install/', - registration_token: Gitlab::CurrentSettings.runners_registration_token + registration_token: Gitlab::CurrentSettings.runners_registration_token, + online_contact_timeout_secs: 7200, + stale_timeout_secs: 7889238 }) end end @@ -121,12 +130,14 @@ RSpec.describe Ci::RunnersHelper do let(:group) { create(:group) } it 'returns group data to render a runner list' do - data = helper.group_runners_data_attributes(group) - - expect(data[:registration_token]).to eq(group.runners_token) - expect(data[:group_id]).to eq(group.id) - expect(data[:group_full_path]).to eq(group.full_path) - expect(data[:runner_install_help_page]).to eq('https://docs.gitlab.com/runner/install/') + expect(helper.group_runners_data_attributes(group)).to eq({ + registration_token: group.runners_token, + group_id: group.id, + group_full_path: group.full_path, + runner_install_help_page: 'https://docs.gitlab.com/runner/install/', + online_contact_timeout_secs: 7200, + stale_timeout_secs: 7889238 + }) end end diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index 53d33f2875f..4feb9d1a2cd 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -74,6 +74,10 @@ RSpec.describe ClustersHelper do expect(subject[:add_cluster_path]).to eq("#{project_path(project)}/-/clusters/connect") end + it 'displays create cluster path' do + expect(subject[:new_cluster_docs_path]).to eq("#{project_path(project)}/-/clusters/new_cluster_docs") + end + it 'displays project default branch' do expect(subject[:default_branch_name]).to eq(project.default_branch) end diff --git a/spec/helpers/colors_helper_spec.rb b/spec/helpers/colors_helper_spec.rb new file mode 100644 index 00000000000..ca5cafb7ebe --- /dev/null +++ b/spec/helpers/colors_helper_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ColorsHelper do + using RSpec::Parameterized::TableSyntax + + describe '#hex_color_to_rgb_array' do + context 'valid hex color' do + where(:hex_color, :rgb_array) do + '#000000' | [0, 0, 0] + '#aaaaaa' | [170, 170, 170] + '#cCcCcC' | [204, 204, 204] + '#FFFFFF' | [255, 255, 255] + '#000abc' | [0, 10, 188] + '#123456' | [18, 52, 86] + '#a1b2c3' | [161, 178, 195] + '#000' | [0, 0, 0] + '#abc' | [170, 187, 204] + '#321' | [51, 34, 17] + '#7E2' | [119, 238, 34] + '#fFf' | [255, 255, 255] + end + + with_them do + it 'returns correct RGB array' do + expect(helper.hex_color_to_rgb_array(hex_color)).to eq(rgb_array) + end + end + end + + context 'invalid hex color' do + where(:hex_color) { ['', '0', '#00', '#ffff', '#1234567', 'invalid', [], 1, nil] } + + with_them do + it 'raise ArgumentError' do + expect { helper.hex_color_to_rgb_array(hex_color) }.to raise_error(ArgumentError) + end + end + end + end + + describe '#rgb_array_to_hex_color' do + context 'valid RGB array' do + where(:rgb_array, :hex_color) do + [0, 0, 0] | '#000000' + [0, 0, 255] | '#0000ff' + [0, 255, 0] | '#00ff00' + [255, 0, 0] | '#ff0000' + [12, 34, 56] | '#0c2238' + [222, 111, 88] | '#de6f58' + [255, 255, 255] | '#ffffff' + end + + with_them do + it 'returns correct hex color' do + expect(helper.rgb_array_to_hex_color(rgb_array)).to eq(hex_color) + end + end + end + + context 'invalid RGB array' do + where(:rgb_array) do + [ + '', + '#000000', + 0, + nil, + [], + [0], + [0, 0], + [0, 0, 0, 0], + [-1, 0, 0], + [0, -1, 0], + [0, 0, -1], + [256, 0, 0], + [0, 256, 0], + [0, 0, 256] + ] + end + + with_them do + it 'raise ArgumentError' do + expect { helper.rgb_array_to_hex_color(rgb_array) }.to raise_error(ArgumentError) + end + end + end + end +end diff --git a/spec/helpers/commits_helper_spec.rb b/spec/helpers/commits_helper_spec.rb index 98db185c180..961e7688202 100644 --- a/spec/helpers/commits_helper_spec.rb +++ b/spec/helpers/commits_helper_spec.rb @@ -163,13 +163,7 @@ RSpec.describe CommitsHelper do end end - let(:params) do - { - page: page - } - end - - subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) } + subject { helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, page: page, per: Projects::CommitController::COMMIT_DIFFS_PER_PAGE) } before do allow(helper).to receive(:params).and_return(params) @@ -183,7 +177,7 @@ RSpec.describe CommitsHelper do end it "can change the number of items per page" do - commits = helper.conditionally_paginate_diff_files(diffs_collection, paginate: paginate, per: 10) + commits = helper.conditionally_paginate_diff_files(diffs_collection, page: page, paginate: paginate, per: 10) expect(commits).to be_an(Array) expect(commits.size).to eq(10) diff --git a/spec/helpers/diff_helper_spec.rb b/spec/helpers/diff_helper_spec.rb index 29708f10de4..84e702cd6a9 100644 --- a/spec/helpers/diff_helper_spec.rb +++ b/spec/helpers/diff_helper_spec.rb @@ -290,6 +290,53 @@ RSpec.describe DiffHelper do end end + describe "#diff_nomappinginraw_line" do + using RSpec::Parameterized::TableSyntax + + let(:line) { double("line") } + let(:line_type) { 'line_type' } + + before do + allow(line).to receive(:rich_text).and_return('line_text') + allow(line).to receive(:type).and_return(line_type) + end + + it 'generates only single line num' do + output = diff_nomappinginraw_line(line, ['line_num_1'], nil, ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + expect(output).to have_css 'td:nth-child(2).line_content', text: 'line_text' + expect(output).not_to have_css 'td:nth-child(3)' + end + + it 'generates only both line nums' do + output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + expect(output).to have_css 'td:nth-child(2).line_num_2' + expect(output).to have_css 'td:nth-child(3).line_content', text: 'line_text' + end + + where(:line_type, :added_class) do + 'old-nomappinginraw' | '.old' + 'new-nomappinginraw' | '.new' + 'unchanged-nomappinginraw' | '' + end + + with_them do + it "appends the correct class" do + output = diff_nomappinginraw_line(line, ['line_num_1'], ['line_num_2'], ['line_content']) + + expect(output).to be_html_safe + expect(output).to have_css 'td:nth-child(1).line_num_1' + added_class + expect(output).to have_css 'td:nth-child(2).line_num_2' + added_class + expect(output).to have_css 'td:nth-child(3).line_content' + added_class, text: 'line_text' + end + end + end + describe '#render_overflow_warning?' do using RSpec::Parameterized::TableSyntax @@ -378,16 +425,6 @@ RSpec.describe DiffHelper do end end - describe '#diff_file_path_text' do - it 'returns full path by default' do - expect(diff_file_path_text(diff_file)).to eq(diff_file.new_path) - end - - it 'returns truncated path' do - expect(diff_file_path_text(diff_file, max: 10)).to eq("...open.rb") - end - end - describe "#collapsed_diff_url" do let(:params) do { diff --git a/spec/helpers/environment_helper_spec.rb b/spec/helpers/environment_helper_spec.rb index 8e5f38cd95a..1fcbcd8c4f9 100644 --- a/spec/helpers/environment_helper_spec.rb +++ b/spec/helpers/environment_helper_spec.rb @@ -55,7 +55,7 @@ RSpec.describe EnvironmentHelper do can_destroy_environment: true, can_stop_environment: true, can_admin_environment: true, - environment_metrics_path: environment_metrics_path(environment), + environment_metrics_path: project_metrics_dashboard_path(project, environment: environment), environments_fetch_path: project_environments_path(project, format: :json), environment_edit_path: edit_project_environment_path(project, environment), environment_stop_path: stop_project_environment_path(project, environment), diff --git a/spec/helpers/environments_helper_spec.rb b/spec/helpers/environments_helper_spec.rb index 38f06b19b94..52f02fba4ec 100644 --- a/spec/helpers/environments_helper_spec.rb +++ b/spec/helpers/environments_helper_spec.rb @@ -20,7 +20,7 @@ RSpec.describe EnvironmentsHelper do expect(metrics_data).to include( 'settings_path' => edit_project_integration_path(project, 'prometheus'), 'clusters_path' => project_clusters_path(project), - 'metrics_dashboard_base_path' => environment_metrics_path(environment), + 'metrics_dashboard_base_path' => project_metrics_dashboard_path(project, environment: environment), 'current_environment_name' => environment.name, 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'), 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), diff --git a/spec/helpers/groups/group_members_helper_spec.rb b/spec/helpers/groups/group_members_helper_spec.rb index f5bc587bce3..ab11bc1f5fd 100644 --- a/spec/helpers/groups/group_members_helper_spec.rb +++ b/spec/helpers/groups/group_members_helper_spec.rb @@ -38,7 +38,9 @@ RSpec.describe Groups::GroupMembersHelper do shared_group, members: present_members(members_collection), invited: present_members(invited), - access_requests: present_members(access_requests) + access_requests: present_members(access_requests), + include_relations: [:inherited, :direct], + search: nil ) end @@ -96,6 +98,64 @@ RSpec.describe Groups::GroupMembersHelper do it 'sets `member_path` property' do expect(subject[:group][:member_path]).to eq('/groups/foo-bar/-/group_links/:id') end + + context 'inherited' do + let_it_be(:sub_shared_group) { create(:group, parent: shared_group) } + let_it_be(:sub_shared_with_group) { create(:group) } + let_it_be(:sub_group_group_link) { create(:group_group_link, shared_group: sub_shared_group, shared_with_group: sub_shared_with_group) } + + let_it_be(:subject_group) { sub_shared_group } + + before do + allow(helper).to receive(:group_group_member_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_members/:id') + allow(helper).to receive(:group_group_link_path).with(sub_shared_group, ':id').and_return('/groups/foo-bar/-/group_links/:id') + allow(helper).to receive(:can?).with(current_user, :admin_group_member, sub_shared_group).and_return(true) + allow(helper).to receive(:can?).with(current_user, :export_group_memberships, sub_shared_group).and_return(true) + end + + subject do + helper.group_members_app_data( + sub_shared_group, + members: present_members(members_collection), + invited: present_members(invited), + access_requests: present_members(access_requests), + include_relations: include_relations, + search: nil + ) + end + + using RSpec::Parameterized::TableSyntax + + where(:include_relations, :result) do + [:inherited, :direct] | lazy { [group_group_link, sub_group_group_link].map(&:id) } + [:inherited] | lazy { [group_group_link].map(&:id) } + [:direct] | lazy { [sub_group_group_link].map(&:id) } + end + + with_them do + it 'returns correct group links' do + expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result) + end + end + + context 'when group_member_inherited_group disabled' do + before do + stub_feature_flags(group_member_inherited_group: false) + end + + where(:include_relations, :result) do + [:inherited, :direct] | lazy { [sub_group_group_link.id] } + [:inherited] | lazy { [sub_group_group_link.id] } + [:direct] | lazy { [sub_group_group_link.id] } + end + + with_them do + it 'always returns direct member links' do + expect(subject[:group][:members].map { |link| link[:id] }).to match_array(result) + end + end + end + end end context 'when pagination is not available' do diff --git a/spec/helpers/invite_members_helper_spec.rb b/spec/helpers/invite_members_helper_spec.rb index 796d68e290e..859d145eb53 100644 --- a/spec/helpers/invite_members_helper_spec.rb +++ b/spec/helpers/invite_members_helper_spec.rb @@ -19,6 +19,7 @@ RSpec.describe InviteMembersHelper do it 'has expected common attributes' do attributes = { id: project.id, + root_id: project.root_ancestor.id, name: project.name, default_access_level: Gitlab::Access::GUEST, invalid_groups: project.related_group_ids, @@ -35,6 +36,7 @@ RSpec.describe InviteMembersHelper do it 'has expected common attributes' do attributes = { id: project.id, + root_id: project.root_ancestor.id, name: project.name, default_access_level: Gitlab::Access::GUEST } diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index ed50a4daae8..ee5b0145d13 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -288,7 +288,7 @@ RSpec.describe IssuablesHelper do canUpdate: true, canDestroy: true, issuableRef: "##{issue.iid}", - markdownPreviewPath: "/#{@project.full_path}/preview_markdown", + markdownPreviewPath: "/#{@project.full_path}/preview_markdown?target_id=#{issue.iid}&target_type=Issue", markdownDocsPath: '/help/user/markdown', lockVersion: issue.lock_version, projectPath: @project.path, diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index a85b1bd0a48..0f653fdd282 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -74,8 +74,8 @@ RSpec.describe IssuesHelper do expect(helper.award_state_class(awardable, AwardEmoji.all, build(:user))).to eq('disabled') end - it 'returns active string for author' do - expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('active') + it 'returns selected class for author' do + expect(helper.award_state_class(awardable, AwardEmoji.all, upvote.user)).to eq('selected') end it 'is blank for a user that has access to the awardable' do @@ -368,6 +368,16 @@ RSpec.describe IssuesHelper do end end + describe '#issues_form_data' do + it 'returns expected result' do + expected = { + new_issue_path: new_project_issue_path(project) + } + + expect(helper.issues_form_data(project)).to include(expected) + end + end + describe '#issue_manual_ordering_class' do context 'when sorting by relative position' do before do diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb index 00aa0fd1cba..52c1130e818 100644 --- a/spec/helpers/namespaces_helper_spec.rb +++ b/spec/helpers/namespaces_helper_spec.rb @@ -268,4 +268,15 @@ RSpec.describe NamespacesHelper do end end end + + describe '#pipeline_usage_quota_app_data' do + it 'returns a hash with necessary data for the frontend' do + expect(helper.pipeline_usage_quota_app_data(user_group)).to eql({ + namespace_actual_plan_name: user_group.actual_plan_name, + namespace_path: user_group.full_path, + namespace_id: user_group.id, + page_size: Kaminari.config.default_per_page + }) + end + end end diff --git a/spec/helpers/packages_helper_spec.rb b/spec/helpers/packages_helper_spec.rb index d7be4194e67..fc69aee4e04 100644 --- a/spec/helpers/packages_helper_spec.rb +++ b/spec/helpers/packages_helper_spec.rb @@ -65,11 +65,11 @@ RSpec.describe PackagesHelper do end end - describe '#show_cleanup_policy_on_alert' do + describe '#show_cleanup_policy_link' do let_it_be(:user) { create(:user) } let_it_be_with_reload(:container_repository) { create(:container_repository) } - subject { helper.show_cleanup_policy_on_alert(project.reload) } + subject { helper.show_cleanup_policy_link(project.reload) } where(:com, :config_registry, :project_registry, :nil_policy, :container_repositories_exist, :expected_result) do false | false | false | false | false | false diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 8c13afc2b45..01235c7bb51 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -145,6 +145,67 @@ RSpec.describe PreferencesHelper do end end + describe '#user_diffs_colors' do + context 'with a user' do + it "returns user's diffs colors" do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef') + + expect(helper.user_diffs_colors).to eq({ addition: '#123456', deletion: '#abcdef' }) + end + + it 'omits property if nil' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil) + + expect(helper.user_diffs_colors).to eq({ addition: '#123456' }) + end + + it 'omits property if blank' do + stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef') + + expect(helper.user_diffs_colors).to eq({ deletion: '#abcdef' }) + end + end + + context 'without a user' do + it 'returns no properties' do + stub_user + + expect(helper.user_diffs_colors).to eq({}) + end + end + end + + describe '#custom_diff_color_classes' do + context 'with a user' do + it 'returns color classes' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: '#abcdef') + + expect(helper.custom_diff_color_classes) + .to match_array(%w[diff-custom-addition-color diff-custom-deletion-color]) + end + + it 'omits property if nil' do + stub_user(diffs_addition_color: '#123456', diffs_deletion_color: nil) + + expect(helper.custom_diff_color_classes).to match_array(['diff-custom-addition-color']) + end + + it 'omits property if blank' do + stub_user(diffs_addition_color: '', diffs_deletion_color: '#abcdef') + + expect(helper.custom_diff_color_classes).to match_array(['diff-custom-deletion-color']) + end + end + + context 'without a user' do + it 'returns no classes' do + stub_user + + expect(helper.custom_diff_color_classes).to match_array([]) + end + end + end + describe '#language_choices' do include StubLanguagesTranslationPercentage diff --git a/spec/helpers/projects/alert_management_helper_spec.rb b/spec/helpers/projects/alert_management_helper_spec.rb index 0a5c4bedaa6..a78a8add336 100644 --- a/spec/helpers/projects/alert_management_helper_spec.rb +++ b/spec/helpers/projects/alert_management_helper_spec.rb @@ -110,15 +110,34 @@ RSpec.describe Projects::AlertManagementHelper do describe '#alert_management_detail_data' do let(:alert_id) { 1 } let(:issues_path) { project_issues_path(project) } + let(:can_update_alert) { true } + + before do + allow(helper) + .to receive(:can?) + .with(current_user, :update_alert_management_alert, project) + .and_return(can_update_alert) + end it 'returns detail page configuration' do - expect(helper.alert_management_detail_data(project, alert_id)).to eq( + expect(helper.alert_management_detail_data(current_user, project, alert_id)).to eq( 'alert-id' => alert_id, 'project-path' => project_path, 'project-id' => project_id, 'project-issues-path' => issues_path, - 'page' => 'OPERATIONS' + 'page' => 'OPERATIONS', + 'can-update' => 'true' ) end + + context 'when user cannot update alert' do + let(:can_update_alert) { false } + + it 'shows error tracking enablement as disabled' do + expect(helper.alert_management_detail_data(current_user, project, alert_id)).to include( + 'can-update' => 'false' + ) + end + end end end diff --git a/spec/helpers/projects/pipeline_helper_spec.rb b/spec/helpers/projects/pipeline_helper_spec.rb new file mode 100644 index 00000000000..67405ee3b21 --- /dev/null +++ b/spec/helpers/projects/pipeline_helper_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::PipelineHelper do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:raw_pipeline) { create(:ci_pipeline, project: project, ref: 'master', sha: project.commit.id) } + let_it_be(:pipeline) { Ci::PipelinePresenter.new(raw_pipeline, current_user: user)} + + describe '#js_pipeline_tabs_data' do + subject(:pipeline_tabs_data) { helper.js_pipeline_tabs_data(project, pipeline) } + + it 'returns pipeline tabs data' do + expect(pipeline_tabs_data).to include({ + can_generate_codequality_reports: pipeline.can_generate_codequality_reports?.to_json, + graphql_resource_etag: graphql_etag_pipeline_path(pipeline), + metrics_path: namespace_project_ci_prometheus_metrics_histograms_path(namespace_id: project.namespace, project_id: project, format: :json), + pipeline_project_path: project.full_path + }) + end + end +end diff --git a/spec/helpers/projects/security/configuration_helper_spec.rb b/spec/helpers/projects/security/configuration_helper_spec.rb index 4c30ba87897..034bfd27844 100644 --- a/spec/helpers/projects/security/configuration_helper_spec.rb +++ b/spec/helpers/projects/security/configuration_helper_spec.rb @@ -10,4 +10,10 @@ RSpec.describe Projects::Security::ConfigurationHelper do it { is_expected.to eq("https://#{ApplicationHelper.promo_host}/pricing/") } end + + describe 'vulnerability_training_docs_path' do + subject { helper.vulnerability_training_docs_path } + + it { is_expected.to eq(help_page_path('user/application_security/vulnerabilities/index', anchor: 'enable-security-training-for-vulnerabilities')) } + end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index 24d908a5dd3..1cf36fd69cf 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1000,6 +1000,54 @@ RSpec.describe ProjectsHelper do end end + context 'fork security helpers' do + using RSpec::Parameterized::TableSyntax + + describe "#able_to_see_merge_requests?" do + subject { helper.able_to_see_merge_requests?(project, user) } + + where(:can_read_merge_request, :merge_requests_enabled, :expected) do + false | false | false + true | false | false + false | true | false + true | true | true + end + + with_them do + before do + allow(project).to receive(:merge_requests_enabled?).and_return(merge_requests_enabled) + allow(helper).to receive(:can?).with(user, :read_merge_request, project).and_return(can_read_merge_request) + end + + it 'returns the correct response' do + expect(subject).to eq(expected) + end + end + end + + describe "#able_to_see_issues?" do + subject { helper.able_to_see_issues?(project, user) } + + where(:can_read_issues, :issues_enabled, :expected) do + false | false | false + true | false | false + false | true | false + true | true | true + end + + with_them do + before do + allow(project).to receive(:issues_enabled?).and_return(issues_enabled) + allow(helper).to receive(:can?).with(user, :read_issue, project).and_return(can_read_issues) + end + + it 'returns the correct response' do + expect(subject).to eq(expected) + end + end + end + end + describe '#fork_button_disabled_tooltip' do using RSpec::Parameterized::TableSyntax diff --git a/spec/helpers/routing/pseudonymization_helper_spec.rb b/spec/helpers/routing/pseudonymization_helper_spec.rb index 1221917e6b7..cf716931fe2 100644 --- a/spec/helpers/routing/pseudonymization_helper_spec.rb +++ b/spec/helpers/routing/pseudonymization_helper_spec.rb @@ -180,7 +180,7 @@ RSpec.describe ::Routing::PseudonymizationHelper do end context 'when some query params are not required to be masked' do - let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state" } + let(:masked_url) { "http://localhost/dashboard/issues?author_username=masked_author_username&scope=all&state=masked_state&tab=2" } let(:request) do double(:Request, path_parameters: { @@ -189,11 +189,11 @@ RSpec.describe ::Routing::PseudonymizationHelper do }, protocol: 'http', host: 'localhost', - query_string: 'author_username=root&scope=all&state=opened') + query_string: 'author_username=root&scope=all&state=opened&tab=2') end before do - stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope].freeze) + stub_const('Routing::PseudonymizationHelper::MaskHelper::QUERY_PARAMS_TO_NOT_MASK', %w[scope tab].freeze) allow(helper).to receive(:request).and_return(request) end diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 78cc1dcee01..d1be451a759 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -71,7 +71,7 @@ RSpec.describe SearchHelper do create(:group).add_owner(user) result = search_autocomplete_opts("gro").first - expect(result.keys).to match_array(%i[category id label url avatar_url]) + expect(result.keys).to match_array(%i[category id value label url avatar_url]) end it 'includes the users recently viewed issues', :aggregate_failures do @@ -467,6 +467,12 @@ RSpec.describe SearchHelper do describe '#show_user_search_tab?' do subject { show_user_search_tab? } + let(:current_user) { build(:user) } + + before do + allow(self).to receive(:current_user).and_return(current_user) + end + context 'when project search' do before do @project = :some_project @@ -481,20 +487,48 @@ RSpec.describe SearchHelper do end end - context 'when not project search' do + context 'when group search' do + before do + @group = :some_group + end + + context 'when current_user can read_users_list' do + before do + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true) + end + + it { is_expected.to eq(true) } + end + + context 'when current_user cannot read_users_list' do + before do + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false) + end + + it { is_expected.to eq(false) } + end + end + + context 'when global search' do context 'when current_user can read_users_list' do before do - allow(self).to receive(:current_user).and_return(:the_current_user) - allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(true) + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(true) end it { is_expected.to eq(true) } + + context 'when global_search_user_tab feature flag is disabled' do + before do + stub_feature_flags(global_search_users_tab: false) + end + + it { is_expected.to eq(false) } + end end context 'when current_user cannot read_users_list' do before do - allow(self).to receive(:current_user).and_return(:the_current_user) - allow(self).to receive(:can?).with(:the_current_user, :read_users_list).and_return(false) + allow(self).to receive(:can?).with(current_user, :read_users_list).and_return(false) end it { is_expected.to eq(false) } diff --git a/spec/helpers/timeboxes_helper_spec.rb b/spec/helpers/timeboxes_helper_spec.rb index 1b9442c0a09..e31f2df7372 100644 --- a/spec/helpers/timeboxes_helper_spec.rb +++ b/spec/helpers/timeboxes_helper_spec.rb @@ -24,34 +24,6 @@ RSpec.describe TimeboxesHelper do end end - describe '#milestone_counts' do - let(:project) { create(:project) } - let(:counts) { helper.milestone_counts(project.milestones) } - - context 'when there are milestones' do - it 'returns the correct counts' do - create_list(:active_milestone, 2, project: project) - create(:closed_milestone, project: project) - - expect(counts).to eq(opened: 2, closed: 1, all: 3) - end - end - - context 'when there are only milestones of one type' do - it 'returns the correct counts' do - create_list(:active_milestone, 2, project: project) - - expect(counts).to eq(opened: 2, closed: 0, all: 2) - end - end - - context 'when there are no milestones' do - it 'returns the correct counts' do - expect(counts).to eq(opened: 0, closed: 0, all: 0) - end - end - end - describe "#group_milestone_route" do let(:group) { build_stubbed(:group) } let(:subgroup) { build_stubbed(:group, parent: group, name: "Test Subgrp") } diff --git a/spec/helpers/wiki_helper_spec.rb b/spec/helpers/wiki_helper_spec.rb index 0d04ca2b876..5adcbe3334d 100644 --- a/spec/helpers/wiki_helper_spec.rb +++ b/spec/helpers/wiki_helper_spec.rb @@ -145,4 +145,8 @@ RSpec.describe WikiHelper do expect(subject).to include('wiki-directory-nest-level' => 0) end end + + it_behaves_like 'wiki endpoint helpers' do + let_it_be(:page) { create(:wiki_page) } + end end diff --git a/spec/initializers/mail_encoding_patch_spec.rb b/spec/initializers/mail_encoding_patch_spec.rb index 52a0d041f48..12539c9ca52 100644 --- a/spec/initializers/mail_encoding_patch_spec.rb +++ b/spec/initializers/mail_encoding_patch_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true +# rubocop:disable RSpec/VariableDefinition, RSpec/VariableName require 'fast_spec_helper' - require 'mail' require_relative '../../config/initializers/mail_encoding_patch' @@ -205,3 +205,4 @@ RSpec.describe 'Mail quoted-printable transfer encoding patch and Unicode charac end end end +# rubocop:enable RSpec/VariableDefinition, RSpec/VariableName diff --git a/spec/initializers/omniauth_spec.rb b/spec/initializers/omniauth_spec.rb new file mode 100644 index 00000000000..928eac8c533 --- /dev/null +++ b/spec/initializers/omniauth_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'OmniAuth initializer for GitLab' do + let(:load_omniauth_initializer) do + load Rails.root.join('config/initializers/omniauth.rb') + end + + describe '#full_host' do + subject { OmniAuth.config.full_host } + + let(:base_url) { 'http://localhost/test' } + + before do + allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url }) + allow(Gitlab::OmniauthInitializer).to receive(:full_host).and_return('proc') + end + + context 'with feature flags not available' do + before do + expect(Feature).to receive(:feature_flags_available?).and_return(false) + load_omniauth_initializer + end + + it { is_expected.to eq(base_url) } + end + + context 'with the omniauth_initializer_fullhost_proc FF disabled' do + before do + stub_feature_flags(omniauth_initializer_fullhost_proc: false) + load_omniauth_initializer + end + + it { is_expected.to eq(base_url) } + end + + context 'with the omniauth_initializer_fullhost_proc FF disabled' do + before do + load_omniauth_initializer + end + + it { is_expected.to eq('proc') } + end + end +end diff --git a/spec/lib/api/entities/application_setting_spec.rb b/spec/lib/api/entities/application_setting_spec.rb new file mode 100644 index 00000000000..5adb825672c --- /dev/null +++ b/spec/lib/api/entities/application_setting_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Entities::ApplicationSetting do + let_it_be(:application_setting, reload: true) { create(:application_setting) } + + subject(:output) { described_class.new(application_setting).as_json } + + context 'housekeeping_bitmaps_enabled usage is deprecated and always enabled' do + before do + application_setting.housekeeping_bitmaps_enabled = housekeeping_bitmaps_enabled + end + + context 'when housekeeping_bitmaps_enabled db column is false' do + let(:housekeeping_bitmaps_enabled) { false } + + it 'returns true' do + expect(subject[:housekeeping_bitmaps_enabled]).to eq(true) + end + end + + context 'when housekeeping_bitmaps_enabled db column is true' do + let(:housekeeping_bitmaps_enabled) { false } + + it 'returns true' do + expect(subject[:housekeeping_bitmaps_enabled]).to eq(true) + end + end + end +end diff --git a/spec/lib/api/validations/validators/limit_spec.rb b/spec/lib/api/validations/validators/limit_spec.rb index d71dde470cc..0c10e2f74d2 100644 --- a/spec/lib/api/validations/validators/limit_spec.rb +++ b/spec/lib/api/validations/validators/limit_spec.rb @@ -22,4 +22,10 @@ RSpec.describe API::Validations::Validators::Limit do expect_validation_error('test' => "#{'a' * 256}") end end + + context 'value is nil' do + it 'does not raise a validation error' do + expect_no_validation_error('test' => nil) + end + end end diff --git a/spec/lib/backup/artifacts_spec.rb b/spec/lib/backup/artifacts_spec.rb deleted file mode 100644 index d830692d96b..00000000000 --- a/spec/lib/backup/artifacts_spec.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Artifacts do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).with('/var/gitlab-artifacts').and_return('/var/gitlab-artifacts') - allow(File).to receive(:realpath).with('/var/gitlab-artifacts/..').and_return('/var') - allow(JobArtifactUploader).to receive(:root) { '/var/gitlab-artifacts' } - end - - it '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).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - backup.dump('artifacts.tar.gz') - end - end -end diff --git a/spec/lib/backup/files_spec.rb b/spec/lib/backup/files_spec.rb index bbc465a26c9..f98b5e1414f 100644 --- a/spec/lib/backup/files_spec.rb +++ b/spec/lib/backup/files_spec.rb @@ -39,7 +39,7 @@ RSpec.describe Backup::Files do end describe '#restore' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } let(:timestamp) { Time.utc(2017, 3, 22) } @@ -110,7 +110,7 @@ RSpec.describe Backup::Files do end describe '#dump' do - subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) } + subject { described_class.new(progress, '/var/gitlab-pages', excludes: ['@pages.tmp']) } before do allow(subject).to receive(:run_pipeline!).and_return([[true, true], '']) @@ -118,14 +118,14 @@ RSpec.describe Backup::Files do end it 'raises no errors' do - expect { subject.dump('registry.tar.gz') }.not_to raise_error + expect { subject.dump('registry.tar.gz', 'backup_id') }.not_to raise_error end it 'excludes tmp dirs from archive' do expect(subject).to receive(:tar).and_return('blabla-tar') expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args) - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end it 'raises an error on failure' do @@ -133,7 +133,7 @@ RSpec.describe Backup::Files do expect(subject).to receive(:pipeline_succeeded?).and_return(false) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to raise_error(/Failed to create compressed file/) end @@ -149,7 +149,7 @@ RSpec.describe Backup::Files do .with(%w(rsync -a --delete --exclude=lost+found --exclude=/gitlab-pages/@pages.tmp /var/gitlab-pages /var/gitlab-backup)) .and_return(['', 0]) - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end it 'retries if rsync fails due to vanishing files' do @@ -158,7 +158,7 @@ RSpec.describe Backup::Files do .and_return(['rsync failed', 24], ['', 0]) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to output(/files vanished during rsync, retrying/).to_stdout end @@ -168,7 +168,7 @@ RSpec.describe Backup::Files do .and_return(['rsync failed', 1]) expect do - subject.dump('registry.tar.gz') + subject.dump('registry.tar.gz', 'backup_id') end.to output(/rsync failed/).to_stdout .and raise_error(/Failed to create compressed file/) end @@ -176,7 +176,7 @@ RSpec.describe Backup::Files do end describe '#exclude_dirs' do - subject { described_class.new(progress, 'pages', '/var/gitlab-pages', excludes: ['@pages.tmp']) } + subject { described_class.new(progress, '/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']) @@ -188,7 +188,7 @@ RSpec.describe Backup::Files do end describe '#run_pipeline!' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/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) @@ -222,7 +222,7 @@ RSpec.describe Backup::Files do end describe '#pipeline_succeeded?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } it 'returns true if both tar and gzip succeeeded' do expect( @@ -262,7 +262,7 @@ RSpec.describe Backup::Files do end describe '#tar_ignore_non_success?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } context 'if `tar` command exits with 1 exitstatus' do it 'returns true' do @@ -310,7 +310,7 @@ RSpec.describe Backup::Files do end describe '#noncritical_warning?' do - subject { described_class.new(progress, 'registry', '/var/gitlab-registry') } + subject { described_class.new(progress, '/var/gitlab-registry') } it 'returns true if given text matches noncritical warnings list' do expect( diff --git a/spec/lib/backup/gitaly_backup_spec.rb b/spec/lib/backup/gitaly_backup_spec.rb index f5295c2b04c..399e4ffa72b 100644 --- a/spec/lib/backup/gitaly_backup_spec.rb +++ b/spec/lib/backup/gitaly_backup_spec.rb @@ -25,11 +25,11 @@ RSpec.describe Backup::GitalyBackup do progress.close end - subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism, backup_id: backup_id) } + subject { described_class.new(progress, max_parallelism: max_parallelism, storage_parallelism: storage_parallelism) } context 'unknown' do it 'fails to start unknown' do - expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') + expect { subject.start(:unknown, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') end end @@ -44,7 +44,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -65,7 +65,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel', '3', '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -76,7 +76,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -84,10 +84,16 @@ RSpec.describe Backup::GitalyBackup do it 'raises when the exit code not zero' do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end + it 'raises when gitaly_backup_path is not set' do + stub_backup_setting(gitaly_backup_path: nil) + + expect { subject.start(:create, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured') + end + context 'feature flag incremental_repository_backup disabled' do before do stub_feature_flags(incremental_repository_backup: false) @@ -102,7 +108,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'create', '-path', anything).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -146,7 +152,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes through SSL envs' do expect(Open3).to receive(:popen2).with(ssl_env, anything, 'create', '-path', anything, '-layout', 'pointer', '-id', backup_id).and_call_original - subject.start(:create, destination) + subject.start(:create, destination, backup_id: backup_id) subject.finish! end end @@ -171,7 +177,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -194,7 +200,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel', '3', '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.finish! end end @@ -205,7 +211,7 @@ RSpec.describe Backup::GitalyBackup do it 'passes parallel option through' do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything, '-parallel-storage', '3', '-layout', 'pointer').and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.finish! end end @@ -224,7 +230,7 @@ RSpec.describe Backup::GitalyBackup do expect(Open3).to receive(:popen2).with(expected_env, anything, 'restore', '-path', anything).and_call_original - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) subject.enqueue(project, Gitlab::GlRepository::PROJECT) subject.enqueue(project, Gitlab::GlRepository::WIKI) subject.enqueue(project, Gitlab::GlRepository::DESIGN) @@ -245,8 +251,14 @@ RSpec.describe Backup::GitalyBackup do it 'raises when the exit code not zero' do expect(subject).to receive(:bin_path).and_return(Gitlab::Utils.which('false')) - subject.start(:restore, destination) + subject.start(:restore, destination, backup_id: backup_id) expect { subject.finish! }.to raise_error(::Backup::Error, 'gitaly-backup exit status 1') end + + it 'raises when gitaly_backup_path is not set' do + stub_backup_setting(gitaly_backup_path: nil) + + expect { subject.start(:restore, destination, backup_id: backup_id) }.to raise_error(::Backup::Error, 'gitaly-backup binary not found and gitaly_backup_path is not configured') + end end end diff --git a/spec/lib/backup/gitaly_rpc_backup_spec.rb b/spec/lib/backup/gitaly_rpc_backup_spec.rb deleted file mode 100644 index 6cba8c5c9b1..00000000000 --- a/spec/lib/backup/gitaly_rpc_backup_spec.rb +++ /dev/null @@ -1,154 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::GitalyRpcBackup do - let(:progress) { spy(:stdout) } - let(:destination) { File.join(Gitlab.config.backup.path, 'repositories') } - - subject { described_class.new(progress) } - - after do - # make sure we do not leave behind any backup files - FileUtils.rm_rf(File.join(Gitlab.config.backup.path, 'repositories')) - end - - context 'unknown' do - it 'fails to start unknown' do - expect { subject.start(:unknown, destination) }.to raise_error(::Backup::Error, 'unknown backup type: unknown') - end - end - - context 'create' do - RSpec.shared_examples 'creates a repository backup' do - it 'creates repository bundles', :aggregate_failures do - # Add data to the wiki, design repositories, and snippets, so they will be included in the dump. - create(:wiki_page, container: project) - create(:design, :with_file, issue: create(:issue, project: project)) - project_snippet = create(:project_snippet, :repository, project: project) - personal_snippet = create(:personal_snippet, :repository, author: project.first_owner) - - subject.start(:create, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - - expect(File).to exist(File.join(destination, project.disk_path + '.bundle')) - expect(File).to exist(File.join(destination, project.disk_path + '.wiki.bundle')) - expect(File).to exist(File.join(destination, project.disk_path + '.design.bundle')) - expect(File).to exist(File.join(destination, personal_snippet.disk_path + '.bundle')) - expect(File).to exist(File.join(destination, project_snippet.disk_path + '.bundle')) - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:bundle_to_disk) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:create, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.finish! - - expect(progress).to have_received(:puts).with("[Failed] backing up #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end - - context 'hashed storage' do - let_it_be(:project) { create(:project, :repository) } - - it_behaves_like 'creates a repository backup' - end - - context 'legacy storage' do - let_it_be(:project) { create(:project, :repository, :legacy_storage) } - - it_behaves_like 'creates a repository backup' - end - end - - context 'restore' do - let_it_be(:project) { create(:project, :repository) } - let_it_be(:personal_snippet) { create(:personal_snippet, author: project.first_owner) } - let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.first_owner) } - - def copy_bundle_to_backup_path(bundle_name, destination) - FileUtils.mkdir_p(File.join(Gitlab.config.backup.path, 'repositories', File.dirname(destination))) - FileUtils.cp(Rails.root.join('spec/fixtures/lib/backup', bundle_name), File.join(Gitlab.config.backup.path, 'repositories', destination)) - end - - it 'restores from repository bundles', :aggregate_failures do - copy_bundle_to_backup_path('project_repo.bundle', project.disk_path + '.bundle') - copy_bundle_to_backup_path('wiki_repo.bundle', project.disk_path + '.wiki.bundle') - copy_bundle_to_backup_path('design_repo.bundle', project.disk_path + '.design.bundle') - copy_bundle_to_backup_path('personal_snippet_repo.bundle', personal_snippet.disk_path + '.bundle') - copy_bundle_to_backup_path('project_snippet_repo.bundle', project_snippet.disk_path + '.bundle') - - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - - collect_commit_shas = -> (repo) { repo.commits('master', limit: 10).map(&:sha) } - - expect(collect_commit_shas.call(project.repository)).to eq(['393a7d860a5a4c3cc736d7eb00604e3472bb95ec']) - expect(collect_commit_shas.call(project.wiki.repository)).to eq(['c74b9948d0088d703ee1fafeddd9ed9add2901ea']) - expect(collect_commit_shas.call(project.design_repository)).to eq(['c3cd4d7bd73a51a0f22045c3a4c871c435dc959d']) - expect(collect_commit_shas.call(personal_snippet.repository)).to eq(['3b3c067a3bc1d1b695b51e2be30c0f8cf698a06e']) - expect(collect_commit_shas.call(project_snippet.repository)).to eq(['6e44ba56a4748be361a841e759c20e421a1651a1']) - end - - it 'cleans existing repositories', :aggregate_failures do - expect_next_instance_of(DesignManagement::Repository) do |repository| - expect(repository).to receive(:remove) - end - - # 4 times = project repo + wiki repo + project_snippet repo + personal_snippet repo - expect(Repository).to receive(:new).exactly(4).times.and_wrap_original do |method, *original_args| - full_path, container, kwargs = original_args - - repository = method.call(full_path, container, **kwargs) - - expect(repository).to receive(:remove) - - repository - end - - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.enqueue(project, Gitlab::GlRepository::WIKI) - subject.enqueue(project, Gitlab::GlRepository::DESIGN) - subject.enqueue(personal_snippet, Gitlab::GlRepository::SNIPPET) - subject.enqueue(project_snippet, Gitlab::GlRepository::SNIPPET) - subject.finish! - end - - context 'failure' do - before do - allow_next_instance_of(Repository) do |repository| - allow(repository).to receive(:create_repository) { raise 'Fail in tests' } - allow(repository).to receive(:create_from_bundle) { raise 'Fail in tests' } - end - end - - it 'logs an appropriate message', :aggregate_failures do - subject.start(:restore, destination) - subject.enqueue(project, Gitlab::GlRepository::PROJECT) - subject.finish! - - expect(progress).to have_received(:puts).with("[Failed] restoring #{project.full_path} (#{project.disk_path})") - expect(progress).to have_received(:puts).with("Error Fail in tests") - end - end - end -end diff --git a/spec/lib/backup/lfs_spec.rb b/spec/lib/backup/lfs_spec.rb deleted file mode 100644 index a27f60f20d0..00000000000 --- a/spec/lib/backup/lfs_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Lfs do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with('/var/lfs-objects').and_return('/var/lfs-objects') - allow(File).to receive(:realpath).with('/var/lfs-objects/..').and_return('/var') - allow(Settings.lfs).to receive(:storage_path).and_return('/var/lfs-objects') - end - - it 'uses the correct lfs dir in tar command', :aggregate_failures do - expect(backup).to receive(:tar).and_return('blabla-tar') - expect(backup).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found -C /var/lfs-objects -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - - backup.dump('lfs.tar.gz') - end - end -end diff --git a/spec/lib/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb index 9cf78a11bc7..192739d05a7 100644 --- a/spec/lib/backup/manager_spec.rb +++ b/spec/lib/backup/manager_spec.rb @@ -22,13 +22,13 @@ RSpec.describe Backup::Manager do describe '#run_create_task' do let(:enabled) { true } - let(:task) { instance_double(Backup::Task, human_name: 'my task', enabled: enabled) } - let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } } + let(:task) { instance_double(Backup::Task) } + let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, destination_path: 'my_task.tar.gz', human_name: 'my task') } } it 'calls the named task' do expect(task).to receive(:dump) expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... done') subject.run_create_task('my_task') end @@ -37,8 +37,7 @@ RSpec.describe Backup::Manager do let(:enabled) { false } it 'informs the user' do - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [DISABLED]') subject.run_create_task('my_task') end @@ -48,8 +47,7 @@ RSpec.describe Backup::Manager do it 'informs the user' do stub_env('SKIP', 'my_task') - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... ') - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[SKIPPED]') + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Dumping my task ... [SKIPPED]') subject.run_create_task('my_task') end @@ -60,12 +58,10 @@ RSpec.describe Backup::Manager do let(:enabled) { true } let(:pre_restore_warning) { nil } let(:post_restore_warning) { nil } - let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, destination_path: 'my_task.tar.gz') } } + let(:definitions) { { 'my_task' => Backup::Manager::TaskDefinition.new(task: task, enabled: enabled, human_name: 'my task', destination_path: 'my_task.tar.gz') } } let(:backup_information) { {} } let(:task) do instance_double(Backup::Task, - human_name: 'my task', - enabled: enabled, pre_restore_warning: pre_restore_warning, post_restore_warning: post_restore_warning) end @@ -78,7 +74,7 @@ RSpec.describe Backup::Manager do it 'calls the named task' do expect(task).to receive(:restore) expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered subject.run_restore_task('my_task') end @@ -87,8 +83,7 @@ RSpec.describe Backup::Manager do let(:enabled) { false } it 'informs the user' do - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: '[DISABLED]').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... [DISABLED]').ordered subject.run_restore_task('my_task') end @@ -100,7 +95,7 @@ RSpec.describe Backup::Manager do it 'displays and waits for the user' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::TaskHelpers).to receive(:ask_to_continue) expect(task).to receive(:restore) @@ -124,7 +119,7 @@ RSpec.describe Backup::Manager do it 'displays and waits for the user' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered expect(Gitlab::TaskHelpers).to receive(:ask_to_continue) expect(task).to receive(:restore) @@ -134,7 +129,7 @@ RSpec.describe Backup::Manager do it 'does not continue when the user quits' do expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... ').ordered - expect(Gitlab::BackupLogger).to receive(:info).with(message: 'done').ordered + expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Restoring my task ... done').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Watch out!').ordered expect(Gitlab::BackupLogger).to receive(:info).with(message: 'Quitting...').ordered expect(task).to receive(:restore) @@ -148,8 +143,10 @@ RSpec.describe Backup::Manager do end describe '#create' do + let(:incremental_env) { 'false' } let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz task2.tar.gz} } - let(:tar_file) { '1546300800_2019_01_01_12.3_gitlab_backup.tar' } + let(:backup_id) { '1546300800_2019_01_01_12.3' } + let(:tar_file) { "#{backup_id}_gitlab_backup.tar" } let(:tar_system_options) { { out: [tar_file, 'w', Gitlab.config.backup.archive_permissions] } } let(:tar_cmdline) { ['tar', '-cf', '-', *expected_backup_contents, tar_system_options] } let(:backup_information) do @@ -159,24 +156,27 @@ RSpec.describe Backup::Manager do } end - let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true) } - let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true) } + let(:task1) { instance_double(Backup::Task) } + let(:task2) { instance_double(Backup::Task) } let(:definitions) do { - 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'), - 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz') + 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'), + 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz') } end before do + stub_env('INCREMENTAL', incremental_env) allow(ActiveRecord::Base.connection).to receive(:reconnect!) + allow(Gitlab::BackupLogger).to receive(:info) allow(Kernel).to receive(:system).and_return(true) + allow(YAML).to receive(:load_file).and_call_original allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml')) .and_return(backup_information) allow(subject).to receive(:backup_information).and_return(backup_information) - allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz')) - allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')) + allow(task1).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz'), backup_id) + allow(task2).to receive(:dump).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz'), backup_id) end it 'executes tar' do @@ -185,8 +185,22 @@ RSpec.describe Backup::Manager do expect(Kernel).to have_received(:system).with(*tar_cmdline) end + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.create # rubocop:disable Rails/SaveBang + end.to raise_error(Backup::Error, 'Backup failed') + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: "Creating archive #{tar_file} failed") + end + end + context 'when BACKUP is set' do - let(:tar_file) { 'custom_gitlab_backup.tar' } + let(:backup_id) { 'custom' } it 'uses the given value as tar file name' do stub_env('BACKUP', '/ignored/path/custom') @@ -213,6 +227,20 @@ RSpec.describe Backup::Manager do end end + context 'when SKIP env is set' do + let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} } + + before do + stub_env('SKIP', 'task2') + end + + it 'executes tar' do + subject.create # rubocop:disable Rails/SaveBang + + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + end + context 'when the destination is optional' do let(:expected_backup_contents) { %w{backup_information.yml task1.tar.gz} } let(:definitions) do @@ -248,6 +276,7 @@ RSpec.describe Backup::Manager do end before do + allow(Gitlab::BackupLogger).to receive(:info) allow(Dir).to receive(:chdir).and_yield allow(Dir).to receive(:glob).and_return(files) allow(FileUtils).to receive(:rm) @@ -266,7 +295,7 @@ RSpec.describe Backup::Manager do end it 'prints a skipped message' do - expect(progress).to have_received(:puts).with('skipping') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... [SKIPPED]') end end @@ -290,7 +319,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (0 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)') end end @@ -307,7 +336,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (0 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (0 removed)') end end @@ -348,7 +377,7 @@ RSpec.describe Backup::Manager do end it 'prints a done message' do - expect(progress).to have_received(:puts).with('done. (8 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (8 removed)') end end @@ -372,11 +401,11 @@ RSpec.describe Backup::Manager do end it 'sets the correct removed count' do - expect(progress).to have_received(:puts).with('done. (7 removed)') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting old backups ... done. (7 removed)') end it 'prints the error from file that could not be removed' do - expect(progress).to have_received(:puts).with(a_string_matching(message)) + expect(Gitlab::BackupLogger).to have_received(:info).with(message: a_string_matching(message)) end end end @@ -386,6 +415,7 @@ RSpec.describe Backup::Manager do let(:backup_filename) { File.basename(backup_file.path) } before do + allow(Gitlab::BackupLogger).to receive(:info) allow(subject).to receive(:tar_file).and_return(backup_filename) stub_backup_setting( @@ -410,6 +440,23 @@ RSpec.describe Backup::Manager do connection.directories.create(key: Gitlab.config.backup.upload.remote_directory) # rubocop:disable Rails/SaveBang end + context 'skipped upload' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: '12.3', + skipped: ['remote'] + } + end + + it 'informs the user' do + stub_env('SKIP', 'remote') + subject.create # rubocop:disable Rails/SaveBang + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... [SKIPPED]') + end + end + context 'target path' do it 'uses the tar filename by default' do expect_any_instance_of(Fog::Collection).to receive(:create) @@ -462,7 +509,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with AES256)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)') end end @@ -473,7 +520,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with AES256)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with AES256)') end end @@ -488,7 +535,7 @@ RSpec.describe Backup::Manager do it 'sets encryption attributes' do subject.create # rubocop:disable Rails/SaveBang - expect(progress).to have_received(:puts).with("done (encrypted with aws:kms)") + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Uploading backup archive to remote storage directory ... done (encrypted with aws:kms)') end end end @@ -546,15 +593,169 @@ RSpec.describe Backup::Manager do end end end + + context 'incremental' do + let(:incremental_env) { 'true' } + let(:gitlab_version) { Gitlab::VERSION } + let(:backup_id) { "1546300800_2019_01_01_#{gitlab_version}" } + let(:tar_file) { "#{backup_id}_gitlab_backup.tar" } + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: gitlab_version + } + end + + context 'when there are no backup files in the directory' do + before do + allow(Dir).to receive(:glob).and_return([]) + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('No backups found')) + end + end + + context 'when there are two backup files in the directory and BACKUP variable is not set' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_1.2.3_gitlab_backup.tar', + '1451520000_2015_12_31_gitlab_backup.tar' + ] + ) + end + + it 'prints the list of available backups' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('1451606400_2016_01_01_1.2.3\n 1451520000_2015_12_31')) + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('Found more than one backup')) + end + end + + context 'when BACKUP variable is set to a non-existing file' do + before do + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(false) + + stub_env('BACKUP', 'wrong') + end + + it 'fails the operation and prints an error' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(File).to have_received(:exist?).with('wrong_gitlab_backup.tar') + expect(progress).to have_received(:puts) + .with(a_string_matching('The backup file wrong_gitlab_backup.tar does not exist')) + end + end + + context 'when BACKUP variable is set to a correct file' do + let(:backup_id) { '1451606400_2016_01_01_1.2.3' } + let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} } + + before do + allow(Gitlab::BackupLogger).to receive(:info) + allow(Dir).to receive(:glob).and_return( + [ + '1451606400_2016_01_01_1.2.3_gitlab_backup.tar' + ] + ) + allow(File).to receive(:exist?).and_return(true) + allow(Kernel).to receive(:system).and_return(true) + + stub_env('BACKUP', '/ignored/path/1451606400_2016_01_01_1.2.3') + end + + it 'unpacks the file' do + subject.create # rubocop:disable Rails/SaveBang + + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.create # rubocop:disable Rails/SaveBang + end.to raise_error(SystemExit) + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed') + end + end + + context 'on version mismatch' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: "not #{gitlab_version}" + } + end + + it 'stops the process' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('GitLab version mismatch')) + end + end + end + + context 'when there is a non-tarred backup in the directory' do + before do + allow(Dir).to receive(:glob).and_return( + [ + 'backup_information.yml' + ] + ) + allow(File).to receive(:exist?).and_return(true) + end + + it 'selects the non-tarred backup to restore from' do + subject.create # rubocop:disable Rails/SaveBang + + expect(progress).to have_received(:puts) + .with(a_string_matching('Non tarred backup found ')) + end + + context 'on version mismatch' do + let(:backup_information) do + { + backup_created_at: Time.zone.parse('2019-01-01'), + gitlab_version: "not #{gitlab_version}" + } + end + + it 'stops the process' do + expect { subject.create }.to raise_error SystemExit # rubocop:disable Rails/SaveBang + expect(progress).to have_received(:puts) + .with(a_string_matching('GitLab version mismatch')) + end + end + end + end end describe '#restore' do - let(:task1) { instance_double(Backup::Task, human_name: 'task 1', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) } - let(:task2) { instance_double(Backup::Task, human_name: 'task 2', enabled: true, pre_restore_warning: nil, post_restore_warning: nil) } + let(:task1) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) } + let(:task2) { instance_double(Backup::Task, pre_restore_warning: nil, post_restore_warning: nil) } let(:definitions) do { - 'task1' => Backup::Manager::TaskDefinition.new(task: task1, destination_path: 'task1.tar.gz'), - 'task2' => Backup::Manager::TaskDefinition.new(task: task2, destination_path: 'task2.tar.gz') + 'task1' => Backup::Manager::TaskDefinition.new(task: task1, human_name: 'task 1', destination_path: 'task1.tar.gz'), + 'task2' => Backup::Manager::TaskDefinition.new(task: task2, human_name: 'task 2', destination_path: 'task2.tar.gz') } end @@ -570,6 +771,7 @@ RSpec.describe Backup::Manager do Rake.application.rake_require 'tasks/gitlab/shell' Rake.application.rake_require 'tasks/cache' + allow(Gitlab::BackupLogger).to receive(:info) allow(task1).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task1.tar.gz')) allow(task2).to receive(:restore).with(File.join(Gitlab.config.backup.path, 'task2.tar.gz')) allow(YAML).to receive(:load_file).with(File.join(Gitlab.config.backup.path, 'backup_information.yml')) @@ -634,7 +836,10 @@ RSpec.describe Backup::Manager do end context 'when BACKUP variable is set to a correct file' do + let(:tar_cmdline) { %w{tar -xf 1451606400_2016_01_01_1.2.3_gitlab_backup.tar} } + before do + allow(Gitlab::BackupLogger).to receive(:info) allow(Dir).to receive(:glob).and_return( [ '1451606400_2016_01_01_1.2.3_gitlab_backup.tar' @@ -649,8 +854,21 @@ RSpec.describe Backup::Manager do it 'unpacks the file' do subject.restore - expect(Kernel).to have_received(:system) - .with("tar", "-xf", "1451606400_2016_01_01_1.2.3_gitlab_backup.tar") + expect(Kernel).to have_received(:system).with(*tar_cmdline) + end + + context 'tar fails' do + before do + expect(Kernel).to receive(:system).with(*tar_cmdline).and_return(false) + end + + it 'logs a failure' do + expect do + subject.restore + end.to raise_error(SystemExit) + + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Unpacking backup failed') + end end context 'on version mismatch' do @@ -680,7 +898,7 @@ RSpec.describe Backup::Manager do subject.restore - expect(progress).to have_received(:print).with('Deleting backups/tmp ... ') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ') end end end @@ -731,7 +949,7 @@ RSpec.describe Backup::Manager do subject.restore - expect(progress).to have_received(:print).with('Deleting backups/tmp ... ') + expect(Gitlab::BackupLogger).to have_received(:info).with(message: 'Deleting backups/tmp ... ') end end end diff --git a/spec/lib/backup/object_backup_spec.rb b/spec/lib/backup/object_backup_spec.rb deleted file mode 100644 index 85658173b0e..00000000000 --- a/spec/lib/backup/object_backup_spec.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.shared_examples 'backup object' do |setting| - let(:progress) { StringIO.new } - let(:backup_path) { "/var/#{setting}" } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with(backup_path).and_return(backup_path) - allow(File).to receive(:realpath).with("#{backup_path}/..").and_return('/var') - allow(Settings.send(setting)).to receive(:storage_path).and_return(backup_path) - end - - it 'uses the correct storage dir in tar command and excludes tmp', :aggregate_failures 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 #{backup_path} -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - - backup.dump('backup_object.tar.gz') - end - end -end - -RSpec.describe Backup::Packages do - it_behaves_like 'backup object', 'packages' -end - -RSpec.describe Backup::TerraformState do - it_behaves_like 'backup object', 'terraform_state' -end diff --git a/spec/lib/backup/pages_spec.rb b/spec/lib/backup/pages_spec.rb deleted file mode 100644 index 095dda61cf4..00000000000 --- a/spec/lib/backup/pages_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Pages do - let(:progress) { StringIO.new } - - subject { described_class.new(progress) } - - before do - allow(File).to receive(:realpath).with("/var/gitlab-pages").and_return("/var/gitlab-pages") - allow(File).to receive(:realpath).with("/var/gitlab-pages/..").and_return("/var") - end - - describe '#dump' do - it 'excludes tmp from backup tar' do - allow(Gitlab.config.pages).to receive(:path) { '/var/gitlab-pages' } - - expect(subject).to receive(:tar).and_return('blabla-tar') - expect(subject).to receive(:run_pipeline!).with([%w(blabla-tar --exclude=lost+found --exclude=./@pages.tmp -C /var/gitlab-pages -cf - .), 'gzip -c -1'], any_args).and_return([[true, true], '']) - expect(subject).to receive(:pipeline_succeeded?).and_return(true) - subject.dump('pages.tar.gz') - end - end -end diff --git a/spec/lib/backup/repositories_spec.rb b/spec/lib/backup/repositories_spec.rb index db3e507596f..c6f611e727c 100644 --- a/spec/lib/backup/repositories_spec.rb +++ b/spec/lib/backup/repositories_spec.rb @@ -4,18 +4,14 @@ require 'spec_helper' RSpec.describe Backup::Repositories do let(:progress) { spy(:stdout) } - let(:parallel_enqueue) { true } - let(:strategy) { spy(:strategy, parallel_enqueue?: parallel_enqueue) } - let(:max_concurrency) { 1 } - let(:max_storage_concurrency) { 1 } + let(:strategy) { spy(:strategy) } let(:destination) { 'repositories' } + let(:backup_id) { 'backup_id' } subject do described_class.new( progress, - strategy: strategy, - max_concurrency: max_concurrency, - max_storage_concurrency: max_storage_concurrency + strategy: strategy ) end @@ -27,9 +23,9 @@ RSpec.describe Backup::Repositories do project_snippet = create(:project_snippet, :repository, project: project) personal_snippet = create(:personal_snippet, :repository, author: project.first_owner) - subject.dump(destination) + subject.dump(destination, backup_id) - expect(strategy).to have_received(:start).with(:create, destination) + expect(strategy).to have_received(:start).with(:create, destination, backup_id: backup_id) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::PROJECT) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::WIKI) expect(strategy).to have_received(:enqueue).with(project, Gitlab::GlRepository::DESIGN) @@ -51,139 +47,30 @@ RSpec.describe Backup::Repositories do it_behaves_like 'creates repository bundles' end - context 'no concurrency' do - it 'creates the expected number of threads' do - expect(Thread).not_to receive(:new) + describe 'command failure' do + it 'enqueue_project raises an error' do + allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).with(anything, Gitlab::GlRepository::PROJECT).and_raise(IOError) - - expect { subject.dump(destination) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout) - end + expect { subject.dump(destination, backup_id) }.to raise_error(IOError) end - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(destination) - end.count + it 'project query raises an error' do + allow(Project).to receive_message_chain(:includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - create_list(:project, 2, :repository) - - expect do - subject.dump(destination) - end.not_to exceed_query_limit(control_count) + expect { subject.dump(destination, backup_id) }.to raise_error(ActiveRecord::StatementTimeout) end end - context 'concurrency with a strategy without parallel enqueueing support' do - let(:parallel_enqueue) { false } - let(:max_concurrency) { 2 } - let(:max_storage_concurrency) { 2 } - - it 'enqueues all projects sequentially' do - expect(Thread).not_to receive(:new) - - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - end - - [4, 10].each do |max_storage_concurrency| - context "max_storage_concurrency #{max_storage_concurrency}", quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/241701' do - let(:storage_keys) { %w[default test_second_storage] } - let(:max_storage_concurrency) { max_storage_concurrency } - - before do - allow(Gitlab.config.repositories.storages).to receive(:keys).and_return(storage_keys) - end - - it 'creates the expected number of threads' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original + it 'avoids N+1 database queries' do + control_count = ActiveRecord::QueryRecorder.new do + subject.dump(destination, backup_id) + end.count - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) + create_list(:project, 2, :repository) - subject.dump(destination) - end - - context 'with extra max concurrency' do - let(:max_concurrency) { 3 } - - it 'creates the expected number of threads' do - expect(Thread).to receive(:new) - .exactly(storage_keys.length * (max_storage_concurrency + 1)).times - .and_call_original - - expect(strategy).to receive(:start).with(:create, destination) - projects.each do |project| - expect(strategy).to receive(:enqueue).with(project, Gitlab::GlRepository::PROJECT) - end - expect(strategy).to receive(:finish!) - - subject.dump(destination) - end - end - - describe 'command failure' do - it 'enqueue_project raises an error' do - allow(strategy).to receive(:enqueue).and_raise(IOError) - - expect { subject.dump(destination) }.to raise_error(IOError) - end - - it 'project query raises an error' do - allow(Project).to receive_message_chain(:for_repository_storage, :includes, :find_each).and_raise(ActiveRecord::StatementTimeout) - - expect { subject.dump(destination) }.to raise_error(ActiveRecord::StatementTimeout) - end - - context 'misconfigured storages' do - let(:storage_keys) { %w[test_second_storage] } - - it 'raises an error' do - expect { subject.dump(destination) }.to raise_error(Backup::Error, 'repositories.storages in gitlab.yml is misconfigured') - end - end - end - - it 'avoids N+1 database queries' do - control_count = ActiveRecord::QueryRecorder.new do - subject.dump(destination) - end.count - - create_list(:project, 2, :repository) - - expect do - subject.dump(destination) - end.not_to exceed_query_limit(control_count) - end - end + expect do + subject.dump(destination, backup_id) + end.not_to exceed_query_limit(control_count) end end diff --git a/spec/lib/backup/task_spec.rb b/spec/lib/backup/task_spec.rb index b0eb885d3f4..80f1fe01b78 100644 --- a/spec/lib/backup/task_spec.rb +++ b/spec/lib/backup/task_spec.rb @@ -7,15 +7,9 @@ RSpec.describe Backup::Task do subject { described_class.new(progress) } - describe '#human_name' do - it 'must be implemented by the subclass' do - expect { subject.human_name }.to raise_error(NotImplementedError) - end - end - describe '#dump' do it 'must be implemented by the subclass' do - expect { subject.dump('some/path') }.to raise_error(NotImplementedError) + expect { subject.dump('some/path', 'backup_id') }.to raise_error(NotImplementedError) end end diff --git a/spec/lib/backup/uploads_spec.rb b/spec/lib/backup/uploads_spec.rb deleted file mode 100644 index 0cfc80a9cb9..00000000000 --- a/spec/lib/backup/uploads_spec.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Backup::Uploads do - let(:progress) { StringIO.new } - - subject(:backup) { described_class.new(progress) } - - describe '#dump' do - before do - allow(File).to receive(:realpath).and_call_original - allow(File).to receive(:realpath).with('/var/uploads').and_return('/var/uploads') - allow(File).to receive(:realpath).with('/var/uploads/..').and_return('/var') - allow(Gitlab.config.uploads).to receive(:storage_path) { '/var' } - end - - it '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).and_return([[true, true], '']) - expect(backup).to receive(:pipeline_succeeded?).and_return(true) - backup.dump('uploads.tar.gz') - end - end -end diff --git a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb index 94e77663d0f..6e29b910a6c 100644 --- a/spec/lib/banzai/filter/custom_emoji_filter_spec.rb +++ b/spec/lib/banzai/filter/custom_emoji_filter_spec.rb @@ -18,31 +18,30 @@ RSpec.describe Banzai::Filter::CustomEmojiFilter do doc = filter('

      :tanuki:

      ', project: project) expect(doc.css('gl-emoji').first.attributes['title'].value).to eq('tanuki') - expect(doc.css('gl-emoji img').size).to eq 1 end it 'correctly uses the custom emoji URL' do doc = filter('

      :tanuki:

      ') - expect(doc.css('img').first.attributes['src'].value).to eq(custom_emoji.file) + expect(doc.css('gl-emoji').first.attributes['data-fallback-src'].value).to eq(custom_emoji.file) end it 'matches multiple same custom emoji' do doc = filter(':tanuki: :tanuki:') - expect(doc.css('img').size).to eq 2 + expect(doc.css('gl-emoji').size).to eq 2 end it 'matches multiple custom emoji' do doc = filter(':tanuki: (:happy_tanuki:)') - expect(doc.css('img').size).to eq 2 + expect(doc.css('gl-emoji').size).to eq 2 end it 'does not match enclosed colons' do doc = filter('tanuki:tanuki:') - expect(doc.css('img').size).to be 0 + expect(doc.css('gl-emoji').size).to be 0 end it 'does not do N+1 query' do diff --git a/spec/lib/banzai/filter/image_link_filter_spec.rb b/spec/lib/banzai/filter/image_link_filter_spec.rb index 238c3cdb9c1..6326d894b08 100644 --- a/spec/lib/banzai/filter/image_link_filter_spec.rb +++ b/spec/lib/banzai/filter/image_link_filter_spec.rb @@ -46,6 +46,16 @@ RSpec.describe Banzai::Filter::ImageLinkFilter do expect(doc.at_css('img')['data-canonical-src']).to eq doc.at_css('a')['data-canonical-src'] end + it 'moves the data-diagram* attributes' do + doc = filter(%q(), context) + + expect(doc.at_css('a')['data-diagram']).to eq "plantuml" + expect(doc.at_css('a')['data-diagram-src']).to eq "data:text/plain;base64,Qm9iIC0+IFNhcmEgOiBIZWxsbw==" + + expect(doc.at_css('a img')['data-diagram']).to be_nil + expect(doc.at_css('a img')['data-diagram-src']).to be_nil + end + it 'adds no-attachment icon class to the link' do doc = filter(image(path), context) diff --git a/spec/lib/banzai/filter/kroki_filter_spec.rb b/spec/lib/banzai/filter/kroki_filter_spec.rb index c9594ac702d..1fb61ad1991 100644 --- a/spec/lib/banzai/filter/kroki_filter_spec.rb +++ b/spec/lib/banzai/filter/kroki_filter_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do stub_application_setting(kroki_enabled: true, kroki_url: "http://localhost:8000") doc = filter("
      [Pirate|eyeCount: Int|raid();pillage()|\n  [beard]--[parrot]\n  [beard]-:>[foul mouth]\n]
      ") - expect(doc.to_s).to eq '' + expect(doc.to_s).to eq '' end it 'replaces nomnoml pre tag with img tag if both kroki and plantuml are enabled' do @@ -19,7 +19,7 @@ RSpec.describe Banzai::Filter::KrokiFilter do plantuml_url: "http://localhost:8080") doc = filter("
      [Pirate|eyeCount: Int|raid();pillage()|\n  [beard]--[parrot]\n  [beard]-:>[foul mouth]\n]
      ") - expect(doc.to_s).to eq '' + expect(doc.to_s).to eq '' end it 'does not replace nomnoml pre tag with img tag if kroki is disabled' do @@ -44,6 +44,6 @@ RSpec.describe Banzai::Filter::KrokiFilter do text = '[Pirate|eyeCount: Int|raid();pillage()|\n [beard]--[parrot]\n [beard]-:>[foul mouth]\n]' * 25 doc = filter("
      #{text}
      ") - expect(doc.to_s).to eq '' + expect(doc.to_s).to start_with ' described_class::JSON_TYPE, 'Authorization' => "bearer #{import_token}" }) .to_return( - status: 200, + status: status_code, body: { status: status }.to_json, headers: { content_type: 'application/json' } ) end - def stub_repository_details(path, with_size: true, status_code: 200, respond_with: {}) + def stub_import_cancel(path, http_status, status: nil, force: false) + body = {} + + if http_status == 400 + body = { status: status } + end + + headers = { + 'Accept' => described_class::JSON_TYPE, + 'Authorization' => "bearer #{import_token}", + 'User-Agent' => "GitLab/#{Gitlab::VERSION}", + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3' + } + + params = force ? '?force=true' : '' + + stub_request(:delete, "#{registry_api_url}/gitlab/v1/import/#{path}/#{params}") + .with(headers: headers) + .to_return( + status: http_status, + body: body.to_json, + headers: { content_type: 'application/json' } + ) + end + + def stub_repository_details(path, sizing: nil, status_code: 200, respond_with: {}) url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/" - url += "?size=self" if with_size + url += "?size=#{sizing}" if sizing + + headers = { 'Accept' => described_class::JSON_TYPE } + headers['Authorization'] = "bearer #{token}" if token + stub_request(:get, url) - .with(headers: { 'Accept' => described_class::JSON_TYPE, 'Authorization' => "bearer #{token}" }) + .with(headers: headers) .to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE }) end end diff --git a/spec/lib/container_registry/migration_spec.rb b/spec/lib/container_registry/migration_spec.rb index ffbbfb249e3..6c0fc94e27f 100644 --- a/spec/lib/container_registry/migration_spec.rb +++ b/spec/lib/container_registry/migration_spec.rb @@ -37,8 +37,8 @@ RSpec.describe ContainerRegistry::Migration do subject { described_class.enqueue_waiting_time } where(:slow_enabled, :fast_enabled, :expected_result) do - false | false | 1.hour - true | false | 6.hours + false | false | 45.minutes + true | false | 165.minutes false | true | 0 true | true | 0 end @@ -154,15 +154,35 @@ RSpec.describe ContainerRegistry::Migration do end end - describe '.target_plan' do - let_it_be(:plan) { create(:plan) } + describe '.target_plans' do + subject { described_class.target_plans } - before do - stub_application_setting(container_registry_import_target_plan: plan.name) + where(:target_plan, :result) do + 'free' | described_class::FREE_TIERS + 'premium' | described_class::PREMIUM_TIERS + 'ultimate' | described_class::ULTIMATE_TIERS end - it 'returns the matching application_setting' do - expect(described_class.target_plan).to eq(plan) + with_them do + before do + stub_application_setting(container_registry_import_target_plan: target_plan) + end + + it { is_expected.to eq(result) } + end + end + + describe '.all_plans?' do + subject { described_class.all_plans? } + + it { is_expected.to eq(true) } + + context 'feature flag disabled' do + before do + stub_feature_flags(container_registry_migration_phase2_all_plans: false) + end + + it { is_expected.to eq(false) } end end end diff --git a/spec/lib/error_tracking/sentry_client/issue_spec.rb b/spec/lib/error_tracking/sentry_client/issue_spec.rb index 82db0f70f2e..d7bb0ca5c9a 100644 --- a/spec/lib/error_tracking/sentry_client/issue_spec.rb +++ b/spec/lib/error_tracking/sentry_client/issue_spec.rb @@ -13,7 +13,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do describe '#list_issues' do shared_examples 'issues have correct return type' do |klass| it "returns objects of type #{klass}" do - expect(subject[:issues]).to all( be_a(klass) ) + expect(subject[:issues]).to all(be_a(klass)) end end @@ -41,10 +41,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do let(:cursor) { nil } let(:sort) { 'last_seen' } let(:sentry_api_response) { issues_sample_response } - let(:sentry_request_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + let(:sentry_request_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" } let!(:sentry_api_request) { stub_sentry_request(sentry_request_url, body: sentry_api_response) } - subject { client.list_issues(issue_status: issue_status, limit: limit, search_term: search_term, sort: sort, cursor: cursor) } + subject do + client.list_issues( + issue_status: issue_status, + limit: limit, + search_term: search_term, + sort: sort, + cursor: cursor + ) + end it_behaves_like 'calls sentry api' @@ -52,7 +60,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'issues have correct length', 3 shared_examples 'has correct external_url' do - context 'external_url' do + describe '#external_url' do it 'is constructed correctly' do expect(subject[:issues][0].external_url).to eq('https://sentrytest.gitlab.com/sentry-org/sentry-project/issues/11') end @@ -62,7 +70,8 @@ RSpec.describe ErrorTracking::SentryClient::Issue do context 'when response has a pagination info' do let(:headers) do { - link: '; rel="previous"; results="true"; cursor="1573556671000:0:1", ; rel="next"; results="true"; cursor="1572959139000:0:0"' + link: '; rel="previous"; results="true"; cursor="1573556671000:0:1",' \ + '; rel="next"; results="true"; cursor="1572959139000:0:0"' } end @@ -76,7 +85,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end end - context 'error object created from sentry response' do + context 'when error object created from sentry response' do using RSpec::Parameterized::TableSyntax where(:error_object, :sentry_response) do @@ -104,13 +113,13 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'has correct external_url' end - context 'redirects' do - let(:sentry_api_url) { sentry_url + '/issues/?limit=20&query=is:unresolved' } + context 'with redirects' do + let(:sentry_api_url) { "#{sentry_url}/issues/?limit=20&query=is:unresolved" } it_behaves_like 'no Sentry redirects' end - context 'requests with sort parameter in sentry api' do + context 'with sort parameter in sentry api' do let(:sentry_request_url) do 'https://sentrytest.gitlab.com/api/0/projects/sentry-org/sentry-project/' \ 'issues/?limit=20&query=is:unresolved&sort=freq' @@ -140,7 +149,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end end - context 'Older sentry versions where keys are not present' do + context 'with older sentry versions where keys are not present' do let(:sentry_api_response) do issues_sample_response[0...1].map do |issue| issue[:project].delete(:id) @@ -156,7 +165,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do it_behaves_like 'has correct external_url' end - context 'essential keys missing in API response' do + context 'when essential keys are missing in API response' do let(:sentry_api_response) do issues_sample_response[0...1].map do |issue| issue.except(:id) @@ -164,16 +173,18 @@ RSpec.describe ErrorTracking::SentryClient::Issue do end it 'raises exception' do - expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, 'Sentry API response is missing keys. key not found: "id"') + expect { subject }.to raise_error(ErrorTracking::SentryClient::MissingKeysError, + 'Sentry API response is missing keys. key not found: "id"') end end - context 'sentry api response too large' do + context 'when sentry api response is too large' do it 'raises exception' do - deep_size = double('Gitlab::Utils::DeepSize', valid?: false) + deep_size = instance_double(Gitlab::Utils::DeepSize, valid?: false) allow(Gitlab::Utils::DeepSize).to receive(:new).with(sentry_api_response).and_return(deep_size) - expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, 'Sentry API response is too big. Limit is 1 MB.') + expect { subject }.to raise_error(ErrorTracking::SentryClient::ResponseInvalidSizeError, + 'Sentry API response is too big. Limit is 1 MB.') end end @@ -212,7 +223,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do subject { client.issue_details(issue_id: issue_id) } - context 'error object created from sentry response' do + context 'with error object created from sentry response' do using RSpec::Parameterized::TableSyntax where(:error_object, :sentry_response) do @@ -298,17 +309,16 @@ RSpec.describe ErrorTracking::SentryClient::Issue do describe '#update_issue' do let(:sentry_url) { 'https://sentrytest.gitlab.com/api/0' } let(:sentry_request_url) { "#{sentry_url}/issues/#{issue_id}/" } - - before do - stub_sentry_request(sentry_request_url, :put) - end - let(:params) do { status: 'resolved' } end + before do + stub_sentry_request(sentry_request_url, :put) + end + subject { client.update_issue(issue_id: issue_id, params: params) } it_behaves_like 'calls sentry api' do @@ -319,7 +329,7 @@ RSpec.describe ErrorTracking::SentryClient::Issue do expect(subject).to be_truthy end - context 'error encountered' do + context 'when error is encountered' do let(:error) { StandardError.new('error') } before do diff --git a/spec/lib/gitlab/application_context_spec.rb b/spec/lib/gitlab/application_context_spec.rb index 55f5ae7d7dc..f9e18a65af4 100644 --- a/spec/lib/gitlab/application_context_spec.rb +++ b/spec/lib/gitlab/application_context_spec.rb @@ -146,7 +146,8 @@ RSpec.describe Gitlab::ApplicationContext do where(:provided_options, :client) do [:remote_ip] | :remote_ip [:remote_ip, :runner] | :runner - [:remote_ip, :runner, :user] | :user + [:remote_ip, :runner, :user] | :runner + [:remote_ip, :user] | :user end with_them do @@ -195,6 +196,16 @@ RSpec.describe Gitlab::ApplicationContext do expect(result(context)).to include(project: nil) end end + + context 'when using job context' do + let_it_be(:job) { create(:ci_build, :pending, :queued, user: user, project: project) } + + it 'sets expected values' do + context = described_class.new(job: job) + + expect(result(context)).to include(job_id: job.id, project: project.full_path, pipeline_id: job.pipeline_id) + end + end end describe '#use' do diff --git a/spec/lib/gitlab/auth/o_auth/user_spec.rb b/spec/lib/gitlab/auth/o_auth/user_spec.rb index 1a9e2f02de6..6cb9085c3ad 100644 --- a/spec/lib/gitlab/auth/o_auth/user_spec.rb +++ b/spec/lib/gitlab/auth/o_auth/user_spec.rb @@ -6,11 +6,15 @@ RSpec.describe Gitlab::Auth::OAuth::User do include LdapHelpers let(:oauth_user) { described_class.new(auth_hash) } + let(:oauth_user_2) { described_class.new(auth_hash_2) } let(:gl_user) { oauth_user.gl_user } + let(:gl_user_2) { oauth_user_2.gl_user } let(:uid) { 'my-uid' } + let(:uid_2) { 'my-uid-2' } let(:dn) { 'uid=user1,ou=people,dc=example' } let(:provider) { 'my-provider' } let(:auth_hash) { OmniAuth::AuthHash.new(uid: uid, provider: provider, info: info_hash) } + let(:auth_hash_2) { OmniAuth::AuthHash.new(uid: uid_2, provider: provider, info: info_hash) } let(:info_hash) do { nickname: '-john+gitlab-ETC%.git@gmail.com', @@ -24,6 +28,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do end let(:ldap_user) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') } + let(:ldap_user_2) { Gitlab::Auth::Ldap::Person.new(Net::LDAP::Entry.new, 'ldapmain') } describe '.find_by_uid_and_provider' do let(:dn) { 'CN=John Åström, CN=Users, DC=Example, DC=com' } @@ -46,12 +51,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do let!(:existing_user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'my-provider') } it "finds an existing user based on uid and provider (facebook)" do - expect( oauth_user.persisted? ).to be_truthy + expect(oauth_user.persisted?).to be_truthy end it 'returns false if user is not found in database' do allow(auth_hash).to receive(:uid).and_return('non-existing') - expect( oauth_user.persisted? ).to be_falsey + expect(oauth_user.persisted?).to be_falsey end end @@ -78,15 +83,27 @@ RSpec.describe Gitlab::Auth::OAuth::User do context 'when signup is disabled' do before do stub_application_setting signup_enabled: false + stub_omniauth_config(allow_single_sign_on: [provider]) end it 'creates the user' do - stub_omniauth_config(allow_single_sign_on: [provider]) - oauth_user.save # rubocop:disable Rails/SaveBang expect(gl_user).to be_persisted end + + it 'does not repeat the default user password' do + oauth_user.save # rubocop:disable Rails/SaveBang + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end + + it 'has the password length within specified range' do + oauth_user.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password.length).to be_between(Devise.password_length.min, Devise.password_length.max) + end end context 'when user confirmation email is enabled' do @@ -330,6 +347,12 @@ RSpec.describe Gitlab::Auth::OAuth::User do allow(ldap_user).to receive(:name) { 'John Doe' } allow(ldap_user).to receive(:email) { ['johndoe@example.com', 'john2@example.com'] } allow(ldap_user).to receive(:dn) { dn } + + allow(ldap_user_2).to receive(:uid) { uid_2 } + allow(ldap_user_2).to receive(:username) { uid_2 } + allow(ldap_user_2).to receive(:name) { 'Beck Potter' } + allow(ldap_user_2).to receive(:email) { ['beckpotter@example.com', 'beck2@example.com'] } + allow(ldap_user_2).to receive(:dn) { dn } end context "and no account for the LDAP user" do @@ -340,6 +363,14 @@ RSpec.describe Gitlab::Auth::OAuth::User do oauth_user.save # rubocop:disable Rails/SaveBang end + it 'does not repeat the default user password' do + allow(Gitlab::Auth::Ldap::Person).to receive(:find_by_uid).and_return(ldap_user_2) + + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end + it "creates a user with dual LDAP and omniauth identities" do expect(gl_user).to be_valid expect(gl_user.username).to eql uid @@ -609,6 +640,7 @@ RSpec.describe Gitlab::Auth::OAuth::User do context 'signup with SAML' do let(:provider) { 'saml' } + let(:block_auto_created_users) { false } before do stub_omniauth_config({ @@ -625,6 +657,13 @@ RSpec.describe Gitlab::Auth::OAuth::User do it_behaves_like 'not being blocked on creation' do let(:block_auto_created_users) { false } end + + it 'does not repeat the default user password' do + oauth_user.save # rubocop:disable Rails/SaveBang + oauth_user_2.save # rubocop:disable Rails/SaveBang + + expect(gl_user.password).not_to eq(gl_user_2.password) + end end context 'signup with omniauth only' do diff --git a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb index a7895623d6f..1158eedfe7c 100644 --- a/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests do +RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests, :migration, schema: 20220326161803 do let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } let(:merge_requests) { table(:merge_requests) } @@ -50,5 +50,19 @@ RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests d subject.perform(mr_ids.first, mr_ids.last) end + + it_behaves_like 'marks background migration job records' do + let!(:non_eligible_mrs) do + Array.new(2) do + create_merge_request( + title: "Not a d-r-a-f-t 1", + draft: false, + state_id: 1 + ) + end + end + + let(:arguments) { [non_eligible_mrs.first.id, non_eligible_mrs.last.id] } + end end end diff --git a/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb new file mode 100644 index 00000000000..4705f0d0ab9 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_group_features_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillGroupFeatures, :migration, schema: 20220302114046 do + let(:group_features) { table(:group_features) } + let(:namespaces) { table(:namespaces) } + + subject { described_class.new(connection: ActiveRecord::Base.connection) } + + describe '#perform' do + it 'creates settings for all group namespaces in range' do + namespaces.create!(id: 1, name: 'group1', path: 'group1', type: 'Group') + namespaces.create!(id: 2, name: 'user', path: 'user') + namespaces.create!(id: 3, name: 'group2', path: 'group2', type: 'Group') + + # Checking that no error is raised if the group_feature for a group already exists + namespaces.create!(id: 4, name: 'group3', path: 'group3', type: 'Group') + group_features.create!(id: 1, group_id: 4) + expect(group_features.count).to eq 1 + + expect { subject.perform(1, 4, :namespaces, :id, 10, 0, 4) }.to change { group_features.count }.by(2) + + expect(group_features.count).to eq 3 + expect(group_features.all.pluck(:group_id)).to contain_exactly(1, 3, 4) + end + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb b/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb deleted file mode 100644 index 242da383453..00000000000 --- a/spec/lib/gitlab/background_migration/backfill_incident_issue_escalation_statuses_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::BackgroundMigration::BackfillIncidentIssueEscalationStatuses, schema: 20211214012507 do - let(:namespaces) { table(:namespaces) } - let(:projects) { table(:projects) } - let(:issues) { table(:issues) } - let(:issuable_escalation_statuses) { table(:incident_management_issuable_escalation_statuses) } - - subject(:migration) { described_class.new } - - it 'correctly backfills issuable escalation status records' do - namespace = namespaces.create!(name: 'foo', path: 'foo') - project = projects.create!(namespace_id: namespace.id) - - issues.create!(project_id: project.id, title: 'issue 1', issue_type: 0) # non-incident issue - issues.create!(project_id: project.id, title: 'incident 1', issue_type: 1) - issues.create!(project_id: project.id, title: 'incident 2', issue_type: 1) - incident_issue_existing_status = issues.create!(project_id: project.id, title: 'incident 3', issue_type: 1) - issuable_escalation_statuses.create!(issue_id: incident_issue_existing_status.id) - - migration.perform(1, incident_issue_existing_status.id) - - expect(issuable_escalation_statuses.count).to eq(3) - end -end diff --git a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb index b29d4c3583b..f98aea2dda7 100644 --- a/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb +++ b/spec/lib/gitlab/background_migration/backfill_issue_search_data_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData do +RSpec.describe Gitlab::BackgroundMigration::BackfillIssueSearchData, :migration, schema: 20220326161803 do let(:namespaces_table) { table(:namespaces) } let(:projects_table) { table(:projects) } let(:issue_search_data_table) { table(:issue_search_data) } diff --git a/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb new file mode 100644 index 00000000000..2dcd4645c84 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_namespace_id_for_project_route_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillNamespaceIdForProjectRoute do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:routes) { table(:routes) } + + let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'space1') } + let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'space2') } + let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'space3') } + + let(:proj_namespace1) { namespaces.create!(name: 'proj1', path: 'proj1', type: 'Project', parent_id: namespace1.id) } + let(:proj_namespace2) { namespaces.create!(name: 'proj2', path: 'proj2', type: 'Project', parent_id: namespace2.id) } + let(:proj_namespace3) { namespaces.create!(name: 'proj3', path: 'proj3', type: 'Project', parent_id: namespace3.id) } + let(:proj_namespace4) { namespaces.create!(name: 'proj4', path: 'proj4', type: 'Project', parent_id: namespace3.id) } + + # rubocop:disable Layout/LineLength + let(:proj1) { projects.create!(name: 'proj1', path: 'proj1', namespace_id: namespace1.id, project_namespace_id: proj_namespace1.id) } + let(:proj2) { projects.create!(name: 'proj2', path: 'proj2', namespace_id: namespace2.id, project_namespace_id: proj_namespace2.id) } + let(:proj3) { projects.create!(name: 'proj3', path: 'proj3', namespace_id: namespace3.id, project_namespace_id: proj_namespace3.id) } + let(:proj4) { projects.create!(name: 'proj4', path: 'proj4', namespace_id: namespace3.id, project_namespace_id: proj_namespace4.id) } + # rubocop:enable Layout/LineLength + + let!(:namespace_route1) { routes.create!(path: 'space1', source_id: namespace1.id, source_type: 'Namespace') } + let!(:namespace_route2) { routes.create!(path: 'space1/space2', source_id: namespace2.id, source_type: 'Namespace') } + let!(:namespace_route3) { routes.create!(path: 'space1/space3', source_id: namespace3.id, source_type: 'Namespace') } + + let!(:proj_route1) { routes.create!(path: 'space1/proj1', source_id: proj1.id, source_type: 'Project') } + let!(:proj_route2) { routes.create!(path: 'space1/space2/proj2', source_id: proj2.id, source_type: 'Project') } + let!(:proj_route3) { routes.create!(path: 'space1/space3/proj3', source_id: proj3.id, source_type: 'Project') } + let!(:proj_route4) { routes.create!(path: 'space1/space3/proj4', source_id: proj4.id, source_type: 'Project') } + + subject(:perform_migration) { migration.perform(proj_route1.id, proj_route4.id, :routes, :id, 2, 0) } + + it 'backfills namespace_id for the selected records', :aggregate_failures do + perform_migration + + expected_namespaces = [proj_namespace1.id, proj_namespace2.id, proj_namespace3.id, proj_namespace4.id] + + expected_projects = [proj_route1.id, proj_route2.id, proj_route3.id, proj_route4.id] + expect(routes.where.not(namespace_id: nil).pluck(:id)).to match_array(expected_projects) + expect(routes.where.not(namespace_id: nil).pluck(:namespace_id)).to match_array(expected_namespaces) + end + + it 'tracks timings of queries' do + expect(migration.batch_metrics.timings).to be_empty + + expect { perform_migration }.to change { migration.batch_metrics.timings } + end +end diff --git a/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb new file mode 100644 index 00000000000..8d82c533d20 --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_work_item_type_id_for_issues_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillWorkItemTypeIdForIssues, :migration, schema: 20220326161803 do + subject(:migrate) { migration.perform(start_id, end_id, batch_table, batch_column, sub_batch_size, pause_ms, issue_type_enum[:issue], issue_type.id) } + + let(:migration) { described_class.new } + + let(:batch_table) { 'issues' } + let(:batch_column) { 'id' } + let(:sub_batch_size) { 2 } + let(:pause_ms) { 0 } + + # let_it_be can't be used in migration specs because all tables but `work_item_types` are deleted after each spec + let(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } } + let(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let(:project) { table(:projects).create!(namespace_id: namespace.id) } + let(:issues_table) { table(:issues) } + let(:issue_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:issue]) } + + let(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) } + # test_case and requirement are EE only, but enum values exist on the FOSS model + let(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) } + let(:requirement1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:requirement]) } + + let(:start_id) { issue1.id } + let(:end_id) { requirement1.id } + + let(:all_issues) { [issue1, issue2, issue3, incident1, test_case1, requirement1] } + + it 'sets work_item_type_id only for the given type' do + expect(all_issues).to all(have_attributes(work_item_type_id: nil)) + + expect { migrate }.to make_queries_matching(/UPDATE \"issues\" SET "work_item_type_id"/, 2) + all_issues.each(&:reload) + + expect([issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: issue_type.id)) + expect(all_issues - [issue1, issue2, issue3]).to all(have_attributes(work_item_type_id: nil)) + end + + it 'tracks timings of queries' do + expect(migration.batch_metrics.timings).to be_empty + + expect { migrate }.to change { migration.batch_metrics.timings } + end + + context 'when database timeouts' do + using RSpec::Parameterized::TableSyntax + + where(error_class: [ActiveRecord::StatementTimeout, ActiveRecord::QueryCanceled]) + + with_them do + it 'retries on timeout error' do + expect(migration).to receive(:update_batch).exactly(3).times.and_raise(error_class) + expect(migration).to receive(:sleep).with(30).twice + + expect do + migrate + end.to raise_error(error_class) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb new file mode 100644 index 00000000000..3cba99bfe51 --- /dev/null +++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_issue_work_item_type_batching_strategy_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillIssueWorkItemTypeBatchingStrategy, '#next_batch', schema: 20220326161803 do # rubocop:disable Layout/LineLength + # let! can't be used in migration specs because all tables but `work_item_types` are deleted after each spec + let!(:issue_type_enum) { { issue: 0, incident: 1, test_case: 2, requirement: 3, task: 4 } } + let!(:namespace) { table(:namespaces).create!(name: 'namespace', path: 'namespace') } + let!(:project) { table(:projects).create!(namespace_id: namespace.id) } + let!(:issues_table) { table(:issues) } + let!(:task_type) { table(:work_item_types).find_by!(namespace_id: nil, base_type: issue_type_enum[:task]) } + + let!(:issue1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:task1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + let!(:issue2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:issue3) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:issue]) } + let!(:task2) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + let!(:incident1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:incident]) } + # test_case is EE only, but enum values exist on the FOSS model + let!(:test_case1) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:test_case]) } + + let!(:task3) do + issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task], work_item_type_id: task_type.id) + end + + let!(:task4) { issues_table.create!(project_id: project.id, issue_type: issue_type_enum[:task]) } + + let!(:batching_strategy) { described_class.new(connection: ActiveRecord::Base.connection) } + + context 'when issue_type is issue' do + let(:job_arguments) { [issue_type_enum[:issue], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue1.id, 2) + + expect(batch_bounds).to match_array([issue1.id, issue2.id]) + end + end + + context 'when additional batches remain' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue2.id, 2) + + expect(batch_bounds).to match_array([issue2.id, issue3.id]) + end + end + + context 'when on the final batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(issue3.id, 2) + + expect(batch_bounds).to match_array([issue3.id, issue3.id]) + end + end + + context 'when no additional batches remain' do + it 'returns nil' do + batch_bounds = next_batch(issue3.id + 1, 1) + + expect(batch_bounds).to be_nil + end + end + end + + context 'when issue_type is incident' do + let(:job_arguments) { [issue_type_enum[:incident], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch with only one element' do + batch_bounds = next_batch(incident1.id, 2) + + expect(batch_bounds).to match_array([incident1.id, incident1.id]) + end + end + end + + context 'when issue_type is requirement and there are no matching records' do + let(:job_arguments) { [issue_type_enum[:requirement], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns nil' do + batch_bounds = next_batch(1, 2) + + expect(batch_bounds).to be_nil + end + end + end + + context 'when issue_type is task' do + let(:job_arguments) { [issue_type_enum[:task], 'irrelevant_work_item_id'] } + + context 'when starting on the first batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(task1.id, 2) + + expect(batch_bounds).to match_array([task1.id, task2.id]) + end + end + + context 'when additional batches remain' do + it 'returns the bounds of the next batch, does not skip records where FK is already set' do + batch_bounds = next_batch(task2.id, 2) + + expect(batch_bounds).to match_array([task2.id, task3.id]) + end + end + + context 'when on the final batch' do + it 'returns the bounds of the next batch' do + batch_bounds = next_batch(task4.id, 2) + + expect(batch_bounds).to match_array([task4.id, task4.id]) + end + end + + context 'when no additional batches remain' do + it 'returns nil' do + batch_bounds = next_batch(task4.id + 1, 1) + + expect(batch_bounds).to be_nil + end + end + end + + def next_batch(min_value, batch_size) + batching_strategy.next_batch( + :issues, + :id, + batch_min_value: min_value, + batch_size: batch_size, + job_arguments: job_arguments + ) + end +end diff --git a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb index b01dd5b410e..dc0935efa94 100644 --- a/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb +++ b/spec/lib/gitlab/background_migration/batching_strategies/backfill_project_namespace_per_group_batching_strategy_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch' do +RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::BackfillProjectNamespacePerGroupBatchingStrategy, '#next_batch', :migration, schema: 20220326161803 do let!(:namespaces) { table(:namespaces) } let!(:projects) { table(:projects) } let!(:background_migrations) { table(:batched_background_migrations) } diff --git a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb index 4e0ebd4b692..521e2067744 100644 --- a/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb +++ b/spec/lib/gitlab/background_migration/batching_strategies/primary_key_batching_strategy_spec.rb @@ -15,7 +15,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when starting on the first batch' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace1.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace1.id, namespace3.id]) end @@ -23,7 +23,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when additional batches remain' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace2.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace2.id, namespace4.id]) end @@ -31,7 +31,7 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when on the final batch' do it 'returns the bounds of the next batch' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: []) expect(batch_bounds).to eq([namespace4.id, namespace4.id]) end @@ -39,9 +39,30 @@ RSpec.describe Gitlab::BackgroundMigration::BatchingStrategies::PrimaryKeyBatchi context 'when no additional batches remain' do it 'returns nil' do - batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: nil) + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id + 1, batch_size: 1, job_arguments: []) expect(batch_bounds).to be_nil end end + + context 'additional filters' do + let(:strategy_with_filters) do + Class.new(described_class) do + def apply_additional_filters(relation, job_arguments:) + min_id = job_arguments.first + + relation.where.not(type: 'Project').where('id >= ?', min_id) + end + end + end + + let(:batching_strategy) { strategy_with_filters.new(connection: ActiveRecord::Base.connection) } + let!(:namespace5) { namespaces.create!(name: 'batchtest5', path: 'batch-test5', type: 'Project') } + + it 'applies additional filters' do + batch_bounds = batching_strategy.next_batch(:namespaces, :id, batch_min_value: namespace4.id, batch_size: 3, job_arguments: [1]) + + expect(batch_bounds).to eq([namespace4.id, namespace4.id]) + end + end end diff --git a/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb new file mode 100644 index 00000000000..d1ef7ca2188 --- /dev/null +++ b/spec/lib/gitlab/background_migration/cleanup_draft_data_from_faulty_regex_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::CleanupDraftDataFromFaultyRegex, :migration, schema: 20220326161803 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:merge_requests) { table(:merge_requests) } + + let(:group) { namespaces.create!(name: 'gitlab', path: 'gitlab') } + let(:project) { projects.create!(namespace_id: group.id) } + + let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] } + + def create_merge_request(params) + common_params = { + target_project_id: project.id, + target_branch: 'feature1', + source_branch: 'master' + } + + merge_requests.create!(common_params.merge(params)) + end + + context "mr.draft == true, and title matches the leaky regex and not the corrected regex" do + let(:mr_ids) { merge_requests.all.collect(&:id) } + + before do + draft_prefixes.each do |prefix| + (1..4).each do |n| + create_merge_request( + title: "#{prefix} This is a title", + draft: true, + state_id: 1 + ) + end + end + + create_merge_request(title: "This has draft in the title", draft: true, state_id: 1) + end + + it "updates all open draft merge request's draft field to true" do + expect { subject.perform(mr_ids.first, mr_ids.last) } + .to change { MergeRequest.where(draft: true).count } + .by(-1) + end + + it "marks successful slices as completed" do + expect(subject).to receive(:mark_job_as_succeeded).with(mr_ids.first, mr_ids.last) + + subject.perform(mr_ids.first, mr_ids.last) + end + end +end diff --git a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb index 04eb9ad475f..8a63673bf38 100644 --- a/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb +++ b/spec/lib/gitlab/background_migration/disable_expiration_policies_linked_to_no_container_images_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages do +RSpec.describe Gitlab::BackgroundMigration::DisableExpirationPoliciesLinkedToNoContainerImages, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let_it_be(:projects) { table(:projects) } let_it_be(:container_expiration_policies) { table(:container_expiration_policies) } let_it_be(:container_repositories) { table(:container_repositories) } diff --git a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb index 94d9f4509a7..4e7b97d33f6 100644 --- a/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb +++ b/spec/lib/gitlab/background_migration/encrypt_static_object_token_spec.rb @@ -39,6 +39,14 @@ RSpec.describe Gitlab::BackgroundMigration::EncryptStaticObjectToken do expect(new_state[user_with_encrypted_token.id]).to match_array([nil, 'encrypted']) end + context 'when id range does not include existing user ids' do + let(:arguments) { [non_existing_record_id, non_existing_record_id.succ] } + + it_behaves_like 'marks background migration job records' do + subject { described_class.new } + end + end + private def create_user!(name:, token: nil, encrypted_token: nil) diff --git a/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb new file mode 100644 index 00000000000..65663d26f37 --- /dev/null +++ b/spec/lib/gitlab/background_migration/fix_duplicate_project_name_and_path_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::FixDuplicateProjectNameAndPath, :migration, schema: 20220325155953 do + let(:migration) { described_class.new } + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:routes) { table(:routes) } + + let(:namespace1) { namespaces.create!(name: 'batchtest1', type: 'Group', path: 'batch-test1') } + let(:namespace2) { namespaces.create!(name: 'batchtest2', type: 'Group', parent_id: namespace1.id, path: 'batch-test2') } + let(:namespace3) { namespaces.create!(name: 'batchtest3', type: 'Group', parent_id: namespace2.id, path: 'batch-test3') } + + let(:project_namespace2) { namespaces.create!(name: 'project2', path: 'project2', type: 'Project', parent_id: namespace2.id, visibility_level: 20) } + let(:project_namespace3) { namespaces.create!(name: 'project3', path: 'project3', type: 'Project', parent_id: namespace3.id, visibility_level: 20) } + + let(:project1) { projects.create!(name: 'project1', path: 'project1', namespace_id: namespace1.id, visibility_level: 20) } + let(:project2) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, project_namespace_id: project_namespace2.id, visibility_level: 20) } + let(:project2_dup) { projects.create!(name: 'project2', path: 'project2', namespace_id: namespace2.id, visibility_level: 20) } + let(:project3) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, project_namespace_id: project_namespace3.id, visibility_level: 20) } + let(:project3_dup) { projects.create!(name: 'project3', path: 'project3', namespace_id: namespace3.id, visibility_level: 20) } + + let!(:namespace_route1) { routes.create!(path: 'batch-test1', source_id: namespace1.id, source_type: 'Namespace') } + let!(:namespace_route2) { routes.create!(path: 'batch-test1/batch-test2', source_id: namespace2.id, source_type: 'Namespace') } + let!(:namespace_route3) { routes.create!(path: 'batch-test1/batch-test3', source_id: namespace3.id, source_type: 'Namespace') } + + let!(:proj_route1) { routes.create!(path: 'batch-test1/project1', source_id: project1.id, source_type: 'Project') } + let!(:proj_route2) { routes.create!(path: 'batch-test1/batch-test2/project2', source_id: project2.id, source_type: 'Project') } + let!(:proj_route2_dup) { routes.create!(path: "batch-test1/batch-test2/project2-route-#{project2_dup.id}", source_id: project2_dup.id, source_type: 'Project') } + let!(:proj_route3) { routes.create!(path: 'batch-test1/batch-test3/project3', source_id: project3.id, source_type: 'Project') } + let!(:proj_route3_dup) { routes.create!(path: "batch-test1/batch-test3/project3-route-#{project3_dup.id}", source_id: project3_dup.id, source_type: 'Project') } + + subject(:perform_migration) { migration.perform(projects.minimum(:id), projects.maximum(:id)) } + + describe '#up' do + it 'backfills namespace_id for the selected records', :aggregate_failures do + expect(namespaces.where(type: 'Project').count).to eq(2) + + perform_migration + + expect(namespaces.where(type: 'Project').count).to eq(5) + + expect(project1.reload.name).to eq("project1-#{project1.id}") + expect(project1.path).to eq('project1') + + expect(project2.reload.name).to eq('project2') + expect(project2.path).to eq('project2') + + expect(project2_dup.reload.name).to eq("project2-#{project2_dup.id}") + expect(project2_dup.path).to eq("project2-route-#{project2_dup.id}") + + expect(project3.reload.name).to eq("project3") + expect(project3.path).to eq("project3") + + expect(project3_dup.reload.name).to eq("project3-#{project3_dup.id}") + expect(project3_dup.path).to eq("project3-route-#{project3_dup.id}") + + projects.all.each do |pr| + project_namespace = namespaces.find(pr.project_namespace_id) + expect(project_namespace).to be_in_sync_with_project(pr) + end + end + end +end diff --git a/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb new file mode 100644 index 00000000000..254b4fea698 --- /dev/null +++ b/spec/lib/gitlab/background_migration/merge_topics_with_same_name_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::MergeTopicsWithSameName, schema: 20220223124428 do + def set_avatar(topic_id, avatar) + topic = ::Projects::Topic.find(topic_id) + topic.avatar = avatar + topic.save! + topic.avatar.absolute_path + end + + it 'merges project topics with same case insensitive name' do + namespaces = table(:namespaces) + projects = table(:projects) + topics = table(:topics) + project_topics = table(:project_topics) + + group = namespaces.create!(name: 'group', path: 'group') + project_1 = projects.create!(namespace_id: group.id, visibility_level: 20) + project_2 = projects.create!(namespace_id: group.id, visibility_level: 10) + project_3 = projects.create!(namespace_id: group.id, visibility_level: 0) + topic_1_keep = topics.create!( + name: 'topic1', + description: 'description 1 to keep', + total_projects_count: 2, + non_private_projects_count: 2 + ) + topic_1_remove = topics.create!( + name: 'TOPIC1', + description: 'description 1 to remove', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_2_remove = topics.create!( + name: 'topic2', + total_projects_count: 0 + ) + topic_2_keep = topics.create!( + name: 'TOPIC2', + description: 'description 2 to keep', + total_projects_count: 1 + ) + topic_3_remove_1 = topics.create!( + name: 'topic3', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_3_keep = topics.create!( + name: 'Topic3', + total_projects_count: 2, + non_private_projects_count: 2 + ) + topic_3_remove_2 = topics.create!( + name: 'TOPIC3', + description: 'description 3 to keep', + total_projects_count: 2, + non_private_projects_count: 1 + ) + topic_4_keep = topics.create!( + name: 'topic4' + ) + + project_topics_1 = [] + project_topics_3 = [] + project_topics_removed = [] + + project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_1.id) + project_topics_1 << project_topics.create!(topic_id: topic_1_keep.id, project_id: project_2.id) + project_topics_removed << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_2.id) + project_topics_1 << project_topics.create!(topic_id: topic_1_remove.id, project_id: project_3.id) + + project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_1.id) + project_topics_3 << project_topics.create!(topic_id: topic_3_keep.id, project_id: project_2.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_1.id) + project_topics_3 << project_topics.create!(topic_id: topic_3_remove_1.id, project_id: project_3.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_1.id) + project_topics_removed << project_topics.create!(topic_id: topic_3_remove_2.id, project_id: project_3.id) + + avatar_paths = { + topic_1_keep: set_avatar(topic_1_keep.id, fixture_file_upload('spec/fixtures/avatars/avatar1.png')), + topic_1_remove: set_avatar(topic_1_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar2.png')), + topic_2_remove: set_avatar(topic_2_remove.id, fixture_file_upload('spec/fixtures/avatars/avatar3.png')), + topic_3_remove_1: set_avatar(topic_3_remove_1.id, fixture_file_upload('spec/fixtures/avatars/avatar4.png')), + topic_3_remove_2: set_avatar(topic_3_remove_2.id, fixture_file_upload('spec/fixtures/avatars/avatar5.png')) + } + + subject.perform(%w[topic1 topic2 topic3 topic4]) + + # Topics + [topic_1_keep, topic_2_keep, topic_3_keep, topic_4_keep].each(&:reload) + expect(topic_1_keep.name).to eq('topic1') + expect(topic_1_keep.description).to eq('description 1 to keep') + expect(topic_1_keep.total_projects_count).to eq(3) + expect(topic_1_keep.non_private_projects_count).to eq(2) + expect(topic_2_keep.name).to eq('TOPIC2') + expect(topic_2_keep.description).to eq('description 2 to keep') + expect(topic_2_keep.total_projects_count).to eq(0) + expect(topic_2_keep.non_private_projects_count).to eq(0) + expect(topic_3_keep.name).to eq('Topic3') + expect(topic_3_keep.description).to eq('description 3 to keep') + expect(topic_3_keep.total_projects_count).to eq(3) + expect(topic_3_keep.non_private_projects_count).to eq(2) + expect(topic_4_keep.reload.name).to eq('topic4') + + [topic_1_remove, topic_2_remove, topic_3_remove_1, topic_3_remove_2].each do |topic| + expect { topic.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + # Topic avatars + expect(topic_1_keep.avatar).to eq('avatar1.png') + expect(File.exist?(::Projects::Topic.find(topic_1_keep.id).avatar.absolute_path)).to be_truthy + expect(topic_2_keep.avatar).to eq('avatar3.png') + expect(File.exist?(::Projects::Topic.find(topic_2_keep.id).avatar.absolute_path)).to be_truthy + expect(topic_3_keep.avatar).to eq('avatar4.png') + expect(File.exist?(::Projects::Topic.find(topic_3_keep.id).avatar.absolute_path)).to be_truthy + + [:topic_1_remove, :topic_2_remove, :topic_3_remove_1, :topic_3_remove_2].each do |topic| + expect(File.exist?(avatar_paths[topic])).to be_falsey + end + + # Project Topic assignments + project_topics_1.each do |project_topic| + expect(project_topic.reload.topic_id).to eq(topic_1_keep.id) + end + + project_topics_3.each do |project_topic| + expect(project_topic.reload.topic_id).to eq(topic_3_keep.id) + end + + project_topics_removed.each do |project_topic| + expect { project_topic.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end diff --git a/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb new file mode 100644 index 00000000000..8bc6bb8ae0a --- /dev/null +++ b/spec/lib/gitlab/background_migration/migrate_shimo_confluence_integration_category_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::MigrateShimoConfluenceIntegrationCategory, schema: 20220326161803 do + let(:namespaces) { table(:namespaces) } + let(:projects) { table(:projects) } + let(:integrations) { table(:integrations) } + let(:perform) { described_class.new.perform(1, 5) } + + before do + namespace = namespaces.create!(name: 'test', path: 'test') + projects.create!(id: 1, namespace_id: namespace.id, name: 'gitlab', path: 'gitlab') + integrations.create!(id: 1, active: true, type_new: "Integrations::SlackSlashCommands", + category: 'chat', project_id: 1) + integrations.create!(id: 3, active: true, type_new: "Integrations::Confluence", category: 'common', project_id: 1) + integrations.create!(id: 5, active: true, type_new: "Integrations::Shimo", category: 'common', project_id: 1) + end + + describe '#up' do + it 'updates category to third_party_wiki for Shimo and Confluence' do + perform + + expect(integrations.where(category: 'third_party_wiki').count).to eq(2) + expect(integrations.where(category: 'chat').count).to eq(1) + end + end +end diff --git a/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb new file mode 100644 index 00000000000..0463f5a0c0d --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_container_repository_migration_plan_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::PopulateContainerRepositoryMigrationPlan, schema: 20220316202640 do + let_it_be(:container_repositories) { table(:container_repositories) } + let_it_be(:projects) { table(:projects) } + let_it_be(:namespaces) { table(:namespaces) } + let_it_be(:gitlab_subscriptions) { table(:gitlab_subscriptions) } + let_it_be(:plans) { table(:plans) } + let_it_be(:namespace_statistics) { table(:namespace_statistics) } + + let!(:namepace1) { namespaces.create!(id: 1, type: 'Group', name: 'group1', path: 'group1', traversal_ids: [1]) } + let!(:namepace2) { namespaces.create!(id: 2, type: 'Group', name: 'group2', path: 'group2', traversal_ids: [2]) } + let!(:namepace3) { namespaces.create!(id: 3, type: 'Group', name: 'group3', path: 'group3', traversal_ids: [3]) } + let!(:sub_namespace) { namespaces.create!(id: 4, type: 'Group', name: 'group3', path: 'group3', parent_id: 1, traversal_ids: [1, 4]) } + let!(:plan1) { plans.create!(id: 1, name: 'plan1') } + let!(:plan2) { plans.create!(id: 2, name: 'plan2') } + let!(:gitlab_subscription1) { gitlab_subscriptions.create!(id: 1, namespace_id: 1, hosted_plan_id: 1) } + let!(:gitlab_subscription2) { gitlab_subscriptions.create!(id: 2, namespace_id: 2, hosted_plan_id: 2) } + let!(:project1) { projects.create!(id: 1, name: 'project1', path: 'project1', namespace_id: 4) } + let!(:project2) { projects.create!(id: 2, name: 'project2', path: 'project2', namespace_id: 2) } + let!(:project3) { projects.create!(id: 3, name: 'project3', path: 'project3', namespace_id: 3) } + let!(:container_repository1) { container_repositories.create!(id: 1, name: 'cr1', project_id: 1) } + let!(:container_repository2) { container_repositories.create!(id: 2, name: 'cr2', project_id: 2) } + let!(:container_repository3) { container_repositories.create!(id: 3, name: 'cr3', project_id: 3) } + + let(:migration) { described_class.new } + + subject do + migration.perform(1, 4) + end + + it 'updates the migration_plan to match the actual plan', :aggregate_failures do + expect(Gitlab::Database::BackgroundMigrationJob).to receive(:mark_all_as_succeeded) + .with('PopulateContainerRepositoryMigrationPlan', [1, 4]).and_return(true) + + subject + + expect(container_repository1.reload.migration_plan).to eq('plan1') + expect(container_repository2.reload.migration_plan).to eq('plan2') + expect(container_repository3.reload.migration_plan).to eq(nil) + end +end diff --git a/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb new file mode 100644 index 00000000000..98b2bc437f3 --- /dev/null +++ b/spec/lib/gitlab/background_migration/populate_namespace_statistics_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::PopulateNamespaceStatistics do + let_it_be(:namespaces) { table(:namespaces) } + let_it_be(:namespace_statistics) { table(:namespace_statistics) } + let_it_be(:dependency_proxy_manifests) { table(:dependency_proxy_manifests) } + let_it_be(:dependency_proxy_blobs) { table(:dependency_proxy_blobs) } + + let!(:group1) { namespaces.create!(id: 10, type: 'Group', name: 'group1', path: 'group1') } + let!(:group2) { namespaces.create!(id: 20, type: 'Group', name: 'group2', path: 'group2') } + + let!(:group1_manifest) do + dependency_proxy_manifests.create!(group_id: 10, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123') + end + + let!(:group2_manifest) do + dependency_proxy_manifests.create!(group_id: 20, size: 20, file_name: 'test-file', file: 'test', digest: 'abc123') + end + + let!(:group1_stats) { namespace_statistics.create!(id: 10, namespace_id: 10) } + + let(:ids) { namespaces.pluck(:id) } + let(:statistics) { [] } + + subject(:perform) { described_class.new.perform(ids, statistics) } + + it 'creates/updates all namespace_statistics and updates root storage statistics', :aggregate_failures do + expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group1.id) + expect(Namespaces::ScheduleAggregationWorker).to receive(:perform_async).with(group2.id) + + expect { perform }.to change(namespace_statistics, :count).from(1).to(2) + + namespace_statistics.all.each do |stat| + expect(stat.dependency_proxy_size).to eq 20 + expect(stat.storage_size).to eq 20 + end + end + + context 'when just a stat is passed' do + let(:statistics) { [:dependency_proxy_size] } + + it 'calls the statistics update service with just that stat' do + expect(Groups::UpdateStatisticsService) + .to receive(:new) + .with(anything, statistics: [:dependency_proxy_size]) + .twice.and_call_original + + perform + end + end + + context 'when a statistics update fails' do + before do + error_response = instance_double(ServiceResponse, message: 'an error', error?: true) + + allow_next_instance_of(Groups::UpdateStatisticsService) do |instance| + allow(instance).to receive(:execute).and_return(error_response) + end + end + + it 'logs an error' do + expect_next_instance_of(Gitlab::BackgroundMigration::Logger) do |instance| + expect(instance).to receive(:error).twice + end + + perform + end + end +end diff --git a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb index a265fa95b23..3de84a4e880 100644 --- a/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_vulnerability_reads_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads do +RSpec.describe Gitlab::BackgroundMigration::PopulateVulnerabilityReads, :migration, schema: 20220326161803 do let(:vulnerabilities) { table(:vulnerabilities) } let(:vulnerability_reads) { table(:vulnerability_reads) } let(:vulnerabilities_findings) { table(:vulnerability_occurrences) } diff --git a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb index 2c5de448fbc..2ad561ead87 100644 --- a/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb +++ b/spec/lib/gitlab/background_migration/project_namespaces/backfill_project_namespaces_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration do +RSpec.describe Gitlab::BackgroundMigration::ProjectNamespaces::BackfillProjectNamespaces, :migration, schema: 20220326161803 do include MigrationsHelpers context 'when migrating data', :aggregate_failures do diff --git a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb index f6f4a3f6115..8003159f59e 100644 --- a/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_duplicate_vulnerabilities_findings_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings do +RSpec.describe Gitlab::BackgroundMigration::RemoveDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } diff --git a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb index 28aa9efde4f..07cff32304e 100644 --- a/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb +++ b/spec/lib/gitlab/background_migration/remove_occurrence_pipelines_and_duplicate_vulnerabilities_findings_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings do +RSpec.describe Gitlab::BackgroundMigration::RemoveOccurrencePipelinesAndDuplicateVulnerabilitiesFindings, :migration, schema: 20220326161803 do let(:namespace) { table(:namespaces).create!(name: 'user', path: 'user') } let(:users) { table(:users) } let(:user) { create_user! } diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb index 6aea549b136..d02f7245c15 100644 --- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_encrypted_values_on_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects do +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenEncryptedValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb index cbe762c2680..fd61047d851 100644 --- a/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb +++ b/spec/lib/gitlab/background_migration/reset_duplicate_ci_runners_token_values_on_projects_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects do +RSpec.describe Gitlab::BackgroundMigration::ResetDuplicateCiRunnersTokenValuesOnProjects, :migration, schema: 20220326161803 do # rubocop:disable Layout/LineLength let(:namespaces) { table(:namespaces) } let(:projects) { table(:projects) } diff --git a/spec/lib/gitlab/blame_spec.rb b/spec/lib/gitlab/blame_spec.rb index e22399723ac..f636ce283ae 100644 --- a/spec/lib/gitlab/blame_spec.rb +++ b/spec/lib/gitlab/blame_spec.rb @@ -3,13 +3,31 @@ require 'spec_helper' RSpec.describe Gitlab::Blame do - let(:project) { create(:project, :repository) } + let_it_be(:project) { create(:project, :repository) } + let(:path) { 'files/ruby/popen.rb' } let(:commit) { project.commit('master') } let(:blob) { project.repository.blob_at(commit.id, path) } + let(:range) { nil } + + subject(:blame) { described_class.new(blob, commit, range: range) } + + describe '#first_line' do + subject { blame.first_line } + + it { is_expected.to eq(1) } + + context 'with a range' do + let(:range) { 2..3 } + + it { is_expected.to eq(range.first) } + end + end describe "#groups" do - let(:subject) { described_class.new(blob, commit).groups(highlight: false) } + let(:highlighted) { false } + + subject(:groups) { blame.groups(highlight: highlighted) } it 'groups lines properly' do expect(subject.count).to eq(18) @@ -22,5 +40,62 @@ RSpec.describe Gitlab::Blame do expect(subject[-1][:commit].sha).to eq('913c66a37b4a45b9769037c55c2d238bd0942d2e') expect(subject[-1][:lines]).to eq([" end", "end"]) end + + context 'with a range 1..5' do + let(:range) { 1..5 } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines]).to eq(["require 'fileutils'", "require 'open3'", ""]) + expect(groups[1][:lines]).to eq(['module Popen', ' extend self']) + end + + context 'with highlighted lines' do + let(:highlighted) { true } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines][0]).to match(/LC1.*fileutils/) + expect(groups[0][:lines][1]).to match(/LC2.*open3/) + expect(groups[0][:lines][2]).to eq("\n") + expect(groups[1][:lines][0]).to match(/LC4.*Popen/) + expect(groups[1][:lines][1]).to match(/LC5.*extend/) + end + end + end + + context 'with a range 2..4' do + let(:range) { 2..4 } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines]).to eq(["require 'open3'", ""]) + expect(groups[1][:lines]).to eq(['module Popen']) + end + + context 'with highlighted lines' do + let(:highlighted) { true } + + it 'returns the correct lines' do + expect(groups.count).to eq(2) + expect(groups[0][:lines][0]).to match(/LC2.*open3/) + expect(groups[0][:lines][1]).to eq("\n") + expect(groups[1][:lines][0]).to match(/LC4.*Popen/) + end + end + end + + context 'renamed file' do + let(:path) { 'files/plain_text/renamed' } + let(:commit) { project.commit('blame-on-renamed') } + + it 'adds previous path' do + expect(subject[0][:previous_path]).to be nil + expect(subject[0][:lines]).to match_array(['Initial commit', 'Initial commit']) + + expect(subject[1][:previous_path]).to eq('files/plain_text/initial-commit') + expect(subject[1][:lines]).to match_array(['Renamed as "filename"']) + end + end end end diff --git a/spec/lib/gitlab/ci/build/image_spec.rb b/spec/lib/gitlab/ci/build/image_spec.rb index 71cd57d317c..630dfcd06bb 100644 --- a/spec/lib/gitlab/ci/build/image_spec.rb +++ b/spec/lib/gitlab/ci/build/image_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Ci::Build::Image do subject { described_class.from_image(job) } context 'when image is defined in job' do - let(:image_name) { 'ruby:2.7' } + let(:image_name) { 'image:1.0' } let(:job) { create(:ci_build, options: { image: image_name } ) } context 'when image is defined as string' do diff --git a/spec/lib/gitlab/ci/config/entry/image_spec.rb b/spec/lib/gitlab/ci/config/entry/image_spec.rb index e810d65d560..e16a9a7a74a 100644 --- a/spec/lib/gitlab/ci/config/entry/image_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/image_spec.rb @@ -6,11 +6,11 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do let(:entry) { described_class.new(config) } context 'when configuration is a string' do - let(:config) { 'ruby:2.7' } + let(:config) { 'image:1.0' } describe '#value' do it 'returns image hash' do - expect(entry.value).to eq({ name: 'ruby:2.7' }) + expect(entry.value).to eq({ name: 'image:1.0' }) end end @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do describe '#image' do it "returns image's name" do - expect(entry.name).to eq 'ruby:2.7' + expect(entry.name).to eq 'image:1.0' end end @@ -46,7 +46,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when configuration is a hash' do - let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run) } } + let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run) } } describe '#value' do it 'returns image hash' do @@ -68,7 +68,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do describe '#image' do it "returns image's name" do - expect(entry.name).to eq 'ruby:2.7' + expect(entry.name).to eq 'image:1.0' end end @@ -80,7 +80,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do context 'when configuration has ports' do let(:ports) { [{ number: 80, protocol: 'http', name: 'foobar' }] } - let(:config) { { name: 'ruby:2.7', entrypoint: %w(/bin/sh run), ports: ports } } + let(:config) { { name: 'image:1.0', entrypoint: %w(/bin/sh run), ports: ports } } let(:entry) { described_class.new(config, with_image_ports: image_ports) } let(:image_ports) { false } @@ -112,7 +112,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when entry value is not correct' do - let(:config) { ['ruby:2.7'] } + let(:config) { ['image:1.0'] } describe '#errors' do it 'saves errors' do @@ -129,7 +129,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Image do end context 'when unexpected key is specified' do - let(:config) { { name: 'ruby:2.7', non_existing: 'test' } } + let(:config) { { name: 'image:1.0', non_existing: 'test' } } describe '#errors' do it 'saves errors' do diff --git a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb index 588f53150ff..0fd9a83a4fa 100644 --- a/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/reports/coverage_report_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'fast_spec_helper' +require 'spec_helper' RSpec.describe Gitlab::Ci::Config::Entry::Reports::CoverageReport do let(:entry) { described_class.new(config) } diff --git a/spec/lib/gitlab/ci/config/entry/root_spec.rb b/spec/lib/gitlab/ci/config/entry/root_spec.rb index daf58aff116..b9c32bc51be 100644 --- a/spec/lib/gitlab/ci/config/entry/root_spec.rb +++ b/spec/lib/gitlab/ci/config/entry/root_spec.rb @@ -31,7 +31,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do let(:hash) do { before_script: %w(ls pwd), - image: 'ruby:2.7', + image: 'image:1.0', default: {}, services: ['postgres:9.1', 'mysql:5.5'], variables: { VAR: 'root', VAR2: { value: 'val 2', description: 'this is var 2' } }, @@ -154,7 +154,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { name: :rspec, script: %w[rspec ls], before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -169,7 +169,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { name: :spinach, before_script: [], script: %w[spinach], - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -186,7 +186,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do before_script: [], script: ["make changelog | tee release_changelog.txt"], release: { name: "Release $CI_TAG_NAME", tag_name: 'v0.06', description: "./release_changelog.txt" }, - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "postgres:9.1" }, { name: "mysql:5.5" }], cache: [{ key: "k", untracked: true, paths: ["public/"], policy: "pull-push", when: 'on_success' }], only: { refs: %w(branches tags) }, @@ -206,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do { before_script: %w(ls pwd), after_script: ['make clean'], default: { - image: 'ruby:2.7', + image: 'image:1.0', services: ['postgres:9.1', 'mysql:5.5'] }, variables: { VAR: 'root' }, @@ -233,7 +233,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do rspec: { name: :rspec, script: %w[rspec ls], before_script: %w(ls pwd), - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], @@ -246,7 +246,7 @@ RSpec.describe Gitlab::Ci::Config::Entry::Root do spinach: { name: :spinach, before_script: [], script: %w[spinach], - image: { name: 'ruby:2.7' }, + image: { name: 'image:1.0' }, services: [{ name: 'postgres:9.1' }, { name: 'mysql:5.5' }], stage: 'test', cache: [{ key: 'k', untracked: true, paths: ['public/'], policy: 'pull-push', when: 'on_success' }], diff --git a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb index b59fc95a8cc..9da8d106862 100644 --- a/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/artifact_spec.rb @@ -4,8 +4,9 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Config::External::File::Artifact do let(:parent_pipeline) { create(:ci_pipeline) } + let(:variables) {} let(:context) do - Gitlab::Ci::Config::External::Context.new(parent_pipeline: parent_pipeline) + Gitlab::Ci::Config::External::Context.new(variables: variables, parent_pipeline: parent_pipeline) end let(:external_file) { described_class.new(params, context) } @@ -29,14 +30,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do end describe '#valid?' do - shared_examples 'is invalid' do - it 'is not valid' do - expect(external_file).not_to be_valid - end + subject(:valid?) do + external_file.validate! + external_file.valid? + end + shared_examples 'is invalid' do it 'sets the expected error' do - expect(external_file.errors) - .to contain_exactly(expected_error) + expect(valid?).to be_falsy + expect(external_file.errors).to contain_exactly(expected_error) end end @@ -148,7 +150,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do context 'when file is not empty' do it 'is valid' do - expect(external_file).to be_valid + expect(valid?).to be_truthy expect(external_file.content).to be_present end @@ -160,6 +162,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do user: anything } expect(context).to receive(:mutate).with(expected_attrs).and_call_original + external_file.validate! external_file.content end end @@ -168,6 +171,58 @@ RSpec.describe Gitlab::Ci::Config::External::File::Artifact do end end end + + context 'when job is provided as a variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value', masked: true } + ]) + end + + let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } } + + context 'when job does not exist in the parent pipeline' do + let(:expected_error) do + 'Job `xxxxxxxxxxxxxxxxxxxxxxx` not found in parent pipeline or does not have artifacts!' + end + + it_behaves_like 'is invalid' + end + end + end + end + + describe '#metadata' do + let(:params) { { artifact: 'generated.yml' } } + + subject(:metadata) { external_file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: nil, + type: :artifact, + location: 'generated.yml', + extra: { job_name: nil } + ) + } + + context 'when job name includes a masked variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([{ key: 'VAR1', value: 'a_secret_variable_value', masked: true }]) + end + + let(:params) { { artifact: 'generated.yml', job: 'a_secret_variable_value' } } + + it { + is_expected.to eq( + context_project: nil, + context_sha: nil, + type: :artifact, + location: 'generated.yml', + extra: { job_name: 'xxxxxxxxxxxxxxxxxxxxxxx' } + ) + } end end end diff --git a/spec/lib/gitlab/ci/config/external/file/base_spec.rb b/spec/lib/gitlab/ci/config/external/file/base_spec.rb index 536f48ecba6..280bebe1a7c 100644 --- a/spec/lib/gitlab/ci/config/external/file/base_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/base_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end end - subject { test_class.new(location, context) } + subject(:file) { test_class.new(location, context) } before do allow_any_instance_of(test_class) @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do let(:location) { 'some-location' } it 'returns true' do - expect(subject).to be_matching + expect(file).to be_matching end end @@ -40,40 +40,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do let(:location) { nil } it 'returns false' do - expect(subject).not_to be_matching + expect(file).not_to be_matching end end end describe '#valid?' do + subject(:valid?) do + file.validate! + file.valid? + end + context 'when location is not a string' do let(:location) { %w(some/file.txt other/file.txt) } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location is not a YAML file' do let(:location) { 'some/file.txt' } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location has not a valid naming scheme' do let(:location) { 'some/file/.yml' } - it { is_expected.not_to be_valid } + it { is_expected.to be_falsy } end context 'when location is a valid .yml extension' do let(:location) { 'some/file/config.yml' } - it { is_expected.to be_valid } + it { is_expected.to be_truthy } end context 'when location is a valid .yaml extension' do let(:location) { 'some/file/config.yaml' } - it { is_expected.to be_valid } + it { is_expected.to be_truthy } end context 'when there are YAML syntax errors' do @@ -86,8 +91,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end it 'is not a valid file' do - expect(subject).not_to be_valid - expect(subject.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!') + expect(valid?).to be_falsy + expect(file.error_message).to eq('Included file `some/file/xxxxxxxxxxxxxxxx.yml` does not have valid YAML syntax!') end end end @@ -103,8 +108,56 @@ RSpec.describe Gitlab::Ci::Config::External::File::Base do end it 'does expand hash to include the template' do - expect(subject.to_hash).to include(:before_script) + expect(file.to_hash).to include(:before_script) end end end + + describe '#metadata' do + let(:location) { 'some/file/config.yml' } + + subject(:metadata) { file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: 'HEAD' + ) + } + end + + describe '#eql?' do + let(:location) { 'some/file/config.yml' } + + subject(:eql) { file.eql?(other_file) } + + context 'when the other file has the same params' do + let(:other_file) { test_class.new(location, context) } + + it { is_expected.to eq(true) } + end + + context 'when the other file has not the same params' do + let(:other_file) { test_class.new('some/other/file', context) } + + it { is_expected.to eq(false) } + end + end + + describe '#hash' do + let(:location) { 'some/file/config.yml' } + + subject(:filehash) { file.hash } + + context 'with a project' do + let(:project) { create(:project) } + let(:context_params) { { project: project, sha: 'HEAD', variables: variables } } + + it { is_expected.to eq([location, project.full_path, 'HEAD'].hash) } + end + + context 'without a project' do + it { is_expected.to eq([location, nil, 'HEAD'].hash) } + end + end end diff --git a/spec/lib/gitlab/ci/config/external/file/local_spec.rb b/spec/lib/gitlab/ci/config/external/file/local_spec.rb index b9314dfc44e..c0a0b0009ce 100644 --- a/spec/lib/gitlab/ci/config/external/file/local_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/local_spec.rb @@ -55,6 +55,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do end describe '#valid?' do + subject(:valid?) do + local_file.validate! + local_file.valid? + end + context 'when is a valid local path' do let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' } @@ -62,25 +67,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("image: 'ruby2:2'") end - it 'returns true' do - expect(local_file.valid?).to be_truthy - end + it { is_expected.to be_truthy } end context 'when it is not a valid local path' do let(:location) { '/lib/gitlab/ci/templates/non-existent-file.yml' } - it 'returns false' do - expect(local_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when it is not a yaml file' do let(:location) { '/config/application.rb' } - it 'returns false' do - expect(local_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when it is an empty file' do @@ -89,6 +88,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do it 'returns false and adds an error message about an empty file' do allow_any_instance_of(described_class).to receive(:fetch_local_content).and_return("") + local_file.validate! expect(local_file.errors).to include("Local file `/lib/gitlab/ci/templates/xxxxxx/existent-file.yml` is empty!") end end @@ -98,7 +98,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do let(:sha) { ':' } it 'returns false and adds an error message stating that included file does not exist' do - expect(local_file).not_to be_valid + expect(valid?).to be_falsy expect(local_file.errors).to include("Sha #{sha} is not valid!") end end @@ -140,6 +140,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do let(:location) { '/lib/gitlab/ci/templates/secret_file.yml' } let(:variables) { Gitlab::Ci::Variables::Collection.new([{ 'key' => 'GITLAB_TOKEN', 'value' => 'secret_file', 'masked' => true }]) } + before do + local_file.validate! + end + it 'returns an error message' do expect(local_file.error_message).to eq("Local file `/lib/gitlab/ci/templates/xxxxxxxxxxx.yml` does not exist!") end @@ -174,6 +178,8 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do allow(project.repository).to receive(:blob_data_at).with(sha, another_location) .and_return(another_content) + + local_file.validate! end it 'does expand hash to include the template' do @@ -181,4 +187,20 @@ RSpec.describe Gitlab::Ci::Config::External::File::Local do end end end + + describe '#metadata' do + let(:location) { '/lib/gitlab/ci/templates/existent-file.yml' } + + subject(:metadata) { local_file.metadata } + + it { + is_expected.to eq( + context_project: project.full_path, + context_sha: '12345', + type: :local, + location: location, + extra: {} + ) + } + end end diff --git a/spec/lib/gitlab/ci/config/external/file/project_spec.rb b/spec/lib/gitlab/ci/config/external/file/project_spec.rb index 74720c0a3ca..5d3412a148b 100644 --- a/spec/lib/gitlab/ci/config/external/file/project_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/project_spec.rb @@ -66,6 +66,11 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end describe '#valid?' do + subject(:valid?) do + project_file.validate! + project_file.valid? + end + context 'when a valid path is used' do let(:params) do { project: project.full_path, file: '/file.yml' } @@ -74,18 +79,16 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do let(:root_ref_sha) { project.repository.root_ref_sha } before do - stub_project_blob(root_ref_sha, '/file.yml') { 'image: ruby:2.7' } + stub_project_blob(root_ref_sha, '/file.yml') { 'image: image:1.0' } end - it 'returns true' do - expect(project_file).to be_valid - end + it { is_expected.to be_truthy } context 'when user does not have permission to access file' do let(:context_user) { create(:user) } it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` not found or access denied!") end end @@ -99,12 +102,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do let(:ref_sha) { project.commit('master').sha } before do - stub_project_blob(ref_sha, '/file.yml') { 'image: ruby:2.7' } + stub_project_blob(ref_sha, '/file.yml') { 'image: image:1.0' } end - it 'returns true' do - expect(project_file).to be_valid - end + it { is_expected.to be_truthy } end context 'when an empty file is used' do @@ -120,7 +121,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxx.yml` is empty!") end end @@ -131,7 +132,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` reference `I-Do-Not-Exist` does not exist!") end end @@ -144,7 +145,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include("Project `#{project.full_path}` file `/xxxxxxxxxxxxxxxxxxx.yml` does not exist!") end end @@ -155,10 +156,27 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end it 'returns false' do - expect(project_file).not_to be_valid + expect(valid?).to be_falsy expect(project_file.error_message).to include('Included file `/invalid-file` does not have YAML extension!') end end + + context 'when non-existing project is used with a masked variable' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value', masked: true } + ]) + end + + let(:params) do + { project: 'a_secret_variable_value', file: '/file.yml' } + end + + it 'returns false with masked project name' do + expect(valid?).to be_falsy + expect(project_file.error_message).to include("Project `xxxxxxxxxxxxxxxxxxxxxxx` not found or access denied!") + end + end end describe '#expand_context' do @@ -176,6 +194,45 @@ RSpec.describe Gitlab::Ci::Config::External::File::Project do end end + describe '#metadata' do + let(:params) do + { project: project.full_path, file: '/file.yml' } + end + + subject(:metadata) { project_file.metadata } + + it { + is_expected.to eq( + context_project: context_project.full_path, + context_sha: '12345', + type: :file, + location: '/file.yml', + extra: { project: project.full_path, ref: 'HEAD' } + ) + } + + context 'when project name and ref include masked variables' do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { key: 'VAR1', value: 'a_secret_variable_value1', masked: true }, + { key: 'VAR2', value: 'a_secret_variable_value2', masked: true } + ]) + end + + let(:params) { { project: 'a_secret_variable_value1', ref: 'a_secret_variable_value2', file: '/file.yml' } } + + it { + is_expected.to eq( + context_project: context_project.full_path, + context_sha: '12345', + type: :file, + location: '/file.yml', + extra: { project: 'xxxxxxxxxxxxxxxxxxxxxxxx', ref: 'xxxxxxxxxxxxxxxxxxxxxxxx' } + ) + } + end + end + private def stub_project_blob(ref, path) diff --git a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb index 2613bfbfdcf..5c07c87fd5a 100644 --- a/spec/lib/gitlab/ci/config/external/file/remote_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/remote_spec.rb @@ -54,22 +54,23 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do end describe "#valid?" do + subject(:valid?) do + remote_file.validate! + remote_file.valid? + end + context 'when is a valid remote url' do before do stub_full_request(location).to_return(body: remote_file_content) end - it 'returns true' do - expect(remote_file.valid?).to be_truthy - end + it { is_expected.to be_truthy } end context 'with an irregular url' do let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } - it 'returns false' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'with a timeout' do @@ -77,25 +78,19 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do allow(Gitlab::HTTP).to receive(:get).and_raise(Timeout::Error) end - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'when is not a yaml file' do let(:location) { 'https://asdasdasdaj48ggerexample.com' } - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end context 'with an internal url' do let(:location) { 'http://localhost:8080' } - it 'is falsy' do - expect(remote_file.valid?).to be_falsy - end + it { is_expected.to be_falsy } end end @@ -142,7 +137,10 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do end describe "#error_message" do - subject { remote_file.error_message } + subject(:error_message) do + remote_file.validate! + remote_file.error_message + end context 'when remote file location is not valid' do let(:location) { 'not-valid://gitlab.com/gitlab-org/gitlab-foss/blob/1234/?secret_file.yml' } @@ -201,4 +199,22 @@ RSpec.describe Gitlab::Ci::Config::External::File::Remote do is_expected.to be_empty end end + + describe '#metadata' do + before do + stub_full_request(location).to_return(body: remote_file_content) + end + + subject(:metadata) { remote_file.metadata } + + it { + is_expected.to eq( + context_project: nil, + context_sha: '12345', + type: :remote, + location: 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.xxxxxxxxxxx.yml', + extra: {} + ) + } + end end diff --git a/spec/lib/gitlab/ci/config/external/file/template_spec.rb b/spec/lib/gitlab/ci/config/external/file/template_spec.rb index 66a06de3d28..4da9a933a9f 100644 --- a/spec/lib/gitlab/ci/config/external/file/template_spec.rb +++ b/spec/lib/gitlab/ci/config/external/file/template_spec.rb @@ -45,12 +45,15 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do end describe "#valid?" do + subject(:valid?) do + template_file.validate! + template_file.valid? + end + context 'when is a valid template name' do let(:template) { 'Auto-DevOps.gitlab-ci.yml' } - it 'returns true' do - expect(template_file).to be_valid - end + it { is_expected.to be_truthy } end context 'with invalid template name' do @@ -59,7 +62,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do let(:context_params) { { project: project, sha: '12345', user: user, variables: variables } } it 'returns false' do - expect(template_file).not_to be_valid + expect(valid?).to be_falsy expect(template_file.error_message).to include('`xxxxxxxxxxxxxx.yml` is not a valid location!') end end @@ -68,7 +71,7 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do let(:template) { 'I-Do-Not-Have-This-Template.gitlab-ci.yml' } it 'returns false' do - expect(template_file).not_to be_valid + expect(valid?).to be_falsy expect(template_file.error_message).to include('Included file `I-Do-Not-Have-This-Template.gitlab-ci.yml` is empty or does not exist!') end end @@ -111,4 +114,18 @@ RSpec.describe Gitlab::Ci::Config::External::File::Template do is_expected.to be_empty end end + + describe '#metadata' do + subject(:metadata) { template_file.metadata } + + it { + is_expected.to eq( + context_project: project.full_path, + context_sha: '12345', + type: :template, + location: template, + extra: {} + ) + } + 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 f69feba5e59..2d2adf09a42 100644 --- a/spec/lib/gitlab/ci/config/external/mapper_spec.rb +++ b/spec/lib/gitlab/ci/config/external/mapper_spec.rb @@ -17,10 +17,12 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:file_content) do <<~HEREDOC - image: 'ruby:2.7' + image: 'image:1.0' HEREDOC end + subject(:mapper) { described_class.new(values, context) } + before do stub_full_request(remote_url).to_return(body: file_content) @@ -30,13 +32,13 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do end describe '#process' do - subject { described_class.new(values, context).process } + subject(:process) { mapper.process } context "when single 'include' keyword is defined" do context 'when the string is a local file' do let(:values) do { include: local_file, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -48,7 +50,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a local file hash' do let(:values) do { include: { 'local' => local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -59,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the string is a remote file' do let(:values) do - { include: remote_url, image: 'ruby:2.7' } + { include: remote_url, image: 'image:1.0' } end it 'returns File instances' do @@ -71,7 +73,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a remote file hash' do let(:values) do { include: { 'remote' => remote_url }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -83,7 +85,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when the key is a template file hash' do let(:values) do { include: { 'template' => template_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns File instances' do @@ -98,7 +100,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:remote_url) { 'https://gitlab.com/secret-file.yml' } let(:values) do { include: { 'local' => local_file, 'remote' => remote_url }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns ambigious specification error' do @@ -109,7 +111,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do 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' } + image: 'image:1.0' } end it 'returns File instances' do @@ -121,7 +123,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do 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' } + image: 'image:1.0' } end it 'returns two File instances' do @@ -135,7 +137,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is defined as an array" do let(:values) do { include: [remote_url, local_file], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns Files instances' do @@ -147,7 +149,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is defined as an array of hashes" do let(:values) do { include: [{ remote: remote_url }, { local: local_file }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns Files instances' do @@ -158,7 +160,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when it has ambigious match' do let(:values) do { include: [{ remote: remote_url, local: local_file }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'returns ambigious specification error' do @@ -170,7 +172,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context "when 'include' is not defined" do let(:values) do { - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -185,11 +187,16 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'local' => local_file } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'does not raise an exception' do - expect { subject }.not_to raise_error + expect { process }.not_to raise_error + end + + it 'has expanset with one' do + process + expect(mapper.expandset.size).to eq(1) end end @@ -199,7 +206,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'remote' => remote_url } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end before do @@ -217,7 +224,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do { 'local' => local_file }, { 'remote' => remote_url } ], - image: 'ruby:2.7' } + image: 'image:1.0' } end before do @@ -269,7 +276,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'defined as an array' do let(:values) do { include: [full_local_file_path, remote_url], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -281,7 +288,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'defined as an array of hashes' do let(:values) do { include: [{ local: full_local_file_path }, { remote: remote_url }], - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -303,7 +310,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'project name' do let(:values) do { include: { project: '$CI_PROJECT_PATH', file: local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable', :aggregate_failures do @@ -315,7 +322,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'with multiple files' do let(:values) do { include: { project: project.full_path, file: [full_local_file_path, 'another_file_path.yml'] }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'expands the variable' do @@ -327,7 +334,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do context 'when include variable has an unsupported type for variable expansion' do let(:values) do { include: { project: project.id, file: local_file }, - image: 'ruby:2.7' } + image: 'image:1.0' } end it 'does not invoke expansion for the variable', :aggregate_failures do @@ -365,7 +372,7 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do let(:values) do { include: [{ remote: remote_url }, { local: local_file, rules: [{ if: "$CI_PROJECT_ID == '#{project_id}'" }] }], - image: 'ruby:2.7' } + image: 'image:1.0' } end context 'when the rules matches' do @@ -385,5 +392,27 @@ RSpec.describe Gitlab::Ci::Config::External::Mapper do end end end + + context "when locations are same after masking variables" do + let(:variables) do + Gitlab::Ci::Variables::Collection.new([ + { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file1', 'masked' => true }, + { 'key' => 'GITLAB_TOKEN', 'value' => 'secret-file2', 'masked' => true } + ]) + end + + let(:values) do + { include: [ + { 'local' => 'hello/secret-file1.yml' }, + { 'local' => 'hello/secret-file2.yml' } + ], + image: 'ruby:2.7' } + end + + it 'has expanset with two' do + process + expect(mapper.expandset.size).to eq(2) + 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 97bd74721f2..56cd006717e 100644 --- a/spec/lib/gitlab/ci/config/external/processor_spec.rb +++ b/spec/lib/gitlab/ci/config/external/processor_spec.rb @@ -22,10 +22,10 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end describe "#perform" do - subject { processor.perform } + subject(:perform) { processor.perform } context 'when no external files defined' do - let(:values) { { image: 'ruby:2.7' } } + let(:values) { { image: 'image:1.0' } } it 'returns the same values' do expect(processor.perform).to eq(values) @@ -33,7 +33,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'when an invalid local file is defined' do - let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/non-existent-file.yml', image: 'image:1.0' } } it 'raises an error' do expect { processor.perform }.to raise_error( @@ -45,7 +45,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'when an invalid remote file is defined' do let(:remote_file) { 'http://doesntexist.com/.gitlab-ci-1.yml' } - let(:values) { { include: remote_file, image: 'ruby:2.7' } } + let(:values) { { include: remote_file, image: 'image:1.0' } } before do stub_full_request(remote_file).and_raise(SocketError.new('Some HTTP error')) @@ -61,7 +61,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'with a valid remote external file is defined' do let(:remote_file) { 'https://gitlab.com/gitlab-org/gitlab-foss/blob/1234/.gitlab-ci-1.yml' } - let(:values) { { include: remote_file, image: 'ruby:2.7' } } + let(:values) { { include: remote_file, image: 'image:1.0' } } let(:external_file_content) do <<-HEREDOC before_script: @@ -95,7 +95,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'with a valid local external file is defined' do - let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } } let(:local_file_content) do <<-HEREDOC before_script: @@ -133,7 +133,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: external_files, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -165,7 +165,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do end context 'when external files are defined but not valid' do - let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'ruby:2.7' } } + let(:values) { { include: '/lib/gitlab/ci/templates/template.yml', image: 'image:1.0' } } let(:local_file_content) { 'invalid content file ////' } @@ -187,7 +187,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: remote_file, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -200,7 +200,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do it 'takes precedence' do stub_full_request(remote_file).to_return(body: remote_file_content) - expect(processor.perform[:image]).to eq('ruby:2.7') + expect(processor.perform[:image]).to eq('image:1.0') end end @@ -210,7 +210,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do include: [ { local: '/local/file.yml' } ], - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -262,6 +262,18 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do expect(process_obs_count).to eq(3) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :local, location: '/local/file.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :template, location: 'Ruby.gitlab-ci.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :remote, location: 'http://my.domain.com/config.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :file, location: '/templates/my-workflow.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }, + { type: :local, location: '/templates/my-build.yml', extra: {}, context_project: another_project.full_path, context_sha: another_project.commit.sha } + ) + end end context 'when user is reporter of another project' do @@ -294,7 +306,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'when config includes an external configuration file via SSL web request' do before do stub_full_request('https://sha256.badssl.com/fake.yml', ip_address: '8.8.8.8') - .to_return(body: 'image: ruby:2.6', status: 200) + .to_return(body: 'image: image:1.0', status: 200) stub_full_request('https://self-signed.badssl.com/fake.yml', ip_address: '8.8.8.9') .to_raise(OpenSSL::SSL::SSLError.new('SSL_connect returned=1 errno=0 state=error: certificate verify failed (self signed certificate)')) @@ -303,7 +315,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do context 'with an acceptable certificate' do let(:values) { { include: 'https://sha256.badssl.com/fake.yml' } } - it { is_expected.to include(image: 'ruby:2.6') } + it { is_expected.to include(image: 'image:1.0') } end context 'with a self-signed certificate' do @@ -319,7 +331,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do let(:values) do { include: { project: another_project.full_path, file: '/templates/my-build.yml' }, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -349,7 +361,7 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do project: another_project.full_path, file: ['/templates/my-build.yml', '/templates/my-test.yml'] }, - image: 'ruby:2.7' + image: 'image:1.0' } end @@ -377,13 +389,22 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do output = processor.perform expect(output.keys).to match_array([:image, :my_build, :my_test]) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :file, location: '/templates/my-build.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' }, + { type: :file, location: '/templates/my-test.yml', extra: { project: another_project.full_path, ref: 'HEAD' }, context_project: project.full_path, context_sha: '12345' } + ) + end end context 'when local file path has wildcard' do - let_it_be(:project) { create(:project, :repository) } + let(:project) { create(:project, :repository) } let(:values) do - { include: 'myfolder/*.yml', image: 'ruby:2.7' } + { include: 'myfolder/*.yml', image: 'image:1.0' } end before do @@ -412,6 +433,15 @@ RSpec.describe Gitlab::Ci::Config::External::Processor do output = processor.perform expect(output.keys).to match_array([:image, :my_build, :my_test]) end + + it 'stores includes' do + perform + + expect(context.includes).to contain_exactly( + { type: :local, location: 'myfolder/file1.yml', extra: {}, context_project: project.full_path, context_sha: '12345' }, + { type: :local, location: 'myfolder/file2.yml', extra: {}, context_project: project.full_path, context_sha: '12345' } + ) + end end context 'when rules defined' do diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 05ff1f3618b..3ba6a9059c6 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -20,7 +20,7 @@ RSpec.describe Gitlab::Ci::Config do context 'when config is valid' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 rspec: script: @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Ci::Config do describe '#to_hash' do it 'returns hash created from string' do hash = { - image: 'ruby:2.7', + image: 'image:1.0', rspec: { script: ['gem install rspec', 'rspec'] @@ -104,12 +104,32 @@ RSpec.describe Gitlab::Ci::Config do end it { is_expected.to contain_exactly('Jobs/Deploy.gitlab-ci.yml', 'Jobs/Build.gitlab-ci.yml') } + + it 'stores includes' do + expect(config.metadata[:includes]).to contain_exactly( + { type: :template, + location: 'Jobs/Deploy.gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil }, + { type: :template, + location: 'Jobs/Build.gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil }, + { type: :remote, + location: 'https://example.com/gitlab-ci.yml', + extra: {}, + context_project: nil, + context_sha: nil } + ) + end end context 'when using extendable hash' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 rspec: script: rspec @@ -122,7 +142,7 @@ RSpec.describe Gitlab::Ci::Config do it 'correctly extends the hash' do hash = { - image: 'ruby:2.7', + image: 'image:1.0', rspec: { script: 'rspec' }, test: { extends: 'rspec', @@ -212,7 +232,7 @@ RSpec.describe Gitlab::Ci::Config do let(:yml) do <<-EOS image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -226,12 +246,12 @@ RSpec.describe Gitlab::Ci::Config do context 'in the job image' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec image: - name: ruby:2.7 + name: image:1.0 ports: - 80 EOS @@ -245,11 +265,11 @@ RSpec.describe Gitlab::Ci::Config do context 'in the services' do let(:yml) do <<-EOS - image: ruby:2.7 + image: image:1.0 test: script: rspec - image: ruby:2.7 + image: image:1.0 services: - name: test alias: test @@ -325,7 +345,7 @@ RSpec.describe Gitlab::Ci::Config do - project: '$MAIN_PROJECT' ref: '$REF' file: '$FILENAME' - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -364,7 +384,7 @@ RSpec.describe Gitlab::Ci::Config do it 'returns a composed hash' do composed_hash = { before_script: local_location_hash[:before_script], - image: "ruby:2.7", + image: "image:1.0", rspec: { script: ["bundle exec rspec"] }, variables: remote_file_hash[:variables] } @@ -403,6 +423,26 @@ RSpec.describe Gitlab::Ci::Config do end end end + + it 'stores includes' do + expect(config.metadata[:includes]).to contain_exactly( + { type: :local, + location: local_location, + extra: {}, + context_project: project.full_path, + context_sha: '12345' }, + { type: :remote, + location: remote_location, + extra: {}, + context_project: project.full_path, + context_sha: '12345' }, + { type: :file, + location: '.gitlab-ci.yml', + extra: { project: main_project.full_path, ref: 'HEAD' }, + context_project: project.full_path, + context_sha: '12345' } + ) + end end context "when gitlab_ci.yml has invalid 'include' defined" do @@ -481,7 +521,7 @@ RSpec.describe Gitlab::Ci::Config do include: - #{remote_location} - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -492,7 +532,7 @@ RSpec.describe Gitlab::Ci::Config do end it 'takes precedence' do - expect(config.to_hash).to eq({ image: 'ruby:2.7' }) + expect(config.to_hash).to eq({ image: 'image:1.0' }) end end @@ -699,7 +739,7 @@ RSpec.describe Gitlab::Ci::Config do - #{local_location} - #{other_file_location} - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -718,7 +758,7 @@ RSpec.describe Gitlab::Ci::Config do it 'returns a composed hash' do composed_hash = { before_script: local_location_hash[:before_script], - image: "ruby:2.7", + image: "image:1.0", build: { stage: "build", script: "echo hello" }, rspec: { stage: "test", script: "bundle exec rspec" } } @@ -735,7 +775,7 @@ RSpec.describe Gitlab::Ci::Config do - local: #{local_location} rules: - if: $CI_PROJECT_ID == "#{project_id}" - image: ruby:2.7 + image: image:1.0 HEREDOC end @@ -763,7 +803,7 @@ RSpec.describe Gitlab::Ci::Config do - local: #{local_location} rules: - exists: "#{filename}" - image: ruby:2.7 + image: image:1.0 HEREDOC end diff --git a/spec/lib/gitlab/ci/parsers/security/common_spec.rb b/spec/lib/gitlab/ci/parsers/security/common_spec.rb index 1e96c717a4f..dfc5dec1481 100644 --- a/spec/lib/gitlab/ci/parsers/security/common_spec.rb +++ b/spec/lib/gitlab/ci/parsers/security/common_spec.rb @@ -4,6 +4,18 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Parsers::Security::Common do describe '#parse!' do + let_it_be(:scanner_data) do + { + scan: { + scanner: { + id: "gemnasium", + name: "Gemnasium", + version: "2.1.0" + } + } + } + end + where(vulnerability_finding_signatures_enabled: [true, false]) with_them do let_it_be(:pipeline) { create(:ci_pipeline) } @@ -30,7 +42,9 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do describe 'schema validation' do let(:validator_class) { Gitlab::Ci::Parsers::Security::Validators::SchemaValidator } - let(:parser) { described_class.new('{}', report, vulnerability_finding_signatures_enabled, validate: validate) } + let(:data) { {}.merge(scanner_data) } + let(:json_data) { data.to_json } + let(:parser) { described_class.new(json_data, report, vulnerability_finding_signatures_enabled, validate: validate) } subject(:parse_report) { parser.parse! } @@ -38,172 +52,138 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do allow(validator_class).to receive(:new).and_call_original end - context 'when show_report_validation_warnings is enabled' do - before do - stub_feature_flags(show_report_validation_warnings: true) - end - - context 'when the validate flag is set to `false`' do - let(:validate) { false } - let(:valid?) { false } - let(:errors) { ['foo'] } - - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(errors) - end - - allow(parser).to receive_messages(create_scanner: true, create_scan: true) - end - - it 'instantiates the validator with correct params' do - parse_report - - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) - end - - context 'when the report data is not valid according to the schema' do - it 'adds warnings to the report' do - expect { parse_report }.to change { report.warnings }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'keeps the execution flow as normal' do - parse_report + context 'when the validate flag is set to `false`' do + let(:validate) { false } + let(:valid?) { false } + let(:errors) { ['foo'] } + let(:warnings) { ['bar'] } - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + before do + allow_next_instance_of(validator_class) do |instance| + allow(instance).to receive(:valid?).and_return(valid?) + allow(instance).to receive(:errors).and_return(errors) + allow(instance).to receive(:warnings).and_return(warnings) end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - let(:errors) { [] } - - it 'does not add warnings to the report' do - expect { parse_report }.not_to change { report.errors } - end - - it 'keeps the execution flow as normal' do - parse_report - - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end - end + allow(parser).to receive_messages(create_scanner: true, create_scan: true) end - context 'when the validate flag is set to `true`' do - let(:validate) { true } - let(:valid?) { false } - let(:errors) { ['foo'] } + it 'instantiates the validator with correct params' do + parse_report - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(errors) - end + expect(validator_class).to have_received(:new).with( + report.type, + data.deep_stringify_keys, + report.version, + project: pipeline.project, + scanner: data.dig(:scan, :scanner).deep_stringify_keys + ) + end - allow(parser).to receive_messages(create_scanner: true, create_scan: true) + context 'when the report data is not valid according to the schema' do + it 'adds warnings to the report' do + expect { parse_report }.to change { report.warnings }.from([]).to( + [ + { message: 'foo', type: 'Schema' }, + { message: 'bar', type: 'Schema' } + ] + ) end - it 'instantiates the validator with correct params' do + it 'keeps the execution flow as normal' do parse_report - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end + end - context 'when the report data is not valid according to the schema' do - it 'adds errors to the report' do - expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'does not try to create report entities' do - parse_report + context 'when the report data is valid according to the schema' do + let(:valid?) { true } + let(:errors) { [] } + let(:warnings) { [] } - expect(parser).not_to have_received(:create_scanner) - expect(parser).not_to have_received(:create_scan) - end + it 'does not add errors to the report' do + expect { parse_report }.not_to change { report.errors } end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - let(:errors) { [] } - - it 'does not add errors to the report' do - expect { parse_report }.not_to change { report.errors }.from([]) - end + it 'does not add warnings to the report' do + expect { parse_report }.not_to change { report.warnings } + end - it 'keeps the execution flow as normal' do - parse_report + it 'keeps the execution flow as normal' do + parse_report - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end end end - context 'when show_report_validation_warnings is disabled' do - before do - stub_feature_flags(show_report_validation_warnings: false) - end - - context 'when the validate flag is set as `false`' do - let(:validate) { false } + context 'when the validate flag is set to `true`' do + let(:validate) { true } + let(:valid?) { false } + let(:errors) { ['foo'] } + let(:warnings) { ['bar'] } - it 'does not run the validation logic' do - parse_report - - expect(validator_class).not_to have_received(:new) + before do + allow_next_instance_of(validator_class) do |instance| + allow(instance).to receive(:valid?).and_return(valid?) + allow(instance).to receive(:errors).and_return(errors) + allow(instance).to receive(:warnings).and_return(warnings) end + + allow(parser).to receive_messages(create_scanner: true, create_scan: true) end - context 'when the validate flag is set as `true`' do - let(:validate) { true } - let(:valid?) { false } + it 'instantiates the validator with correct params' do + parse_report - before do - allow_next_instance_of(validator_class) do |instance| - allow(instance).to receive(:valid?).and_return(valid?) - allow(instance).to receive(:errors).and_return(['foo']) - end + expect(validator_class).to have_received(:new).with( + report.type, + data.deep_stringify_keys, + report.version, + project: pipeline.project, + scanner: data.dig(:scan, :scanner).deep_stringify_keys + ) + end - allow(parser).to receive_messages(create_scanner: true, create_scan: true) + context 'when the report data is not valid according to the schema' do + it 'adds errors to the report' do + expect { parse_report }.to change { report.errors }.from([]).to( + [ + { message: 'foo', type: 'Schema' }, + { message: 'bar', type: 'Schema' } + ] + ) end - it 'instantiates the validator with correct params' do + it 'does not try to create report entities' do parse_report - expect(validator_class).to have_received(:new).with(report.type, {}, report.version) + expect(parser).not_to have_received(:create_scanner) + expect(parser).not_to have_received(:create_scan) end + end - context 'when the report data is not valid according to the schema' do - it 'adds errors to the report' do - expect { parse_report }.to change { report.errors }.from([]).to([{ message: 'foo', type: 'Schema' }]) - end - - it 'does not try to create report entities' do - parse_report + context 'when the report data is valid according to the schema' do + let(:valid?) { true } + let(:errors) { [] } + let(:warnings) { [] } - expect(parser).not_to have_received(:create_scanner) - expect(parser).not_to have_received(:create_scan) - end + it 'does not add errors to the report' do + expect { parse_report }.not_to change { report.errors }.from([]) end - context 'when the report data is valid according to the schema' do - let(:valid?) { true } - - it 'does not add errors to the report' do - expect { parse_report }.not_to change { report.errors }.from([]) - end + it 'does not add warnings to the report' do + expect { parse_report }.not_to change { report.warnings }.from([]) + end - it 'keeps the execution flow as normal' do - parse_report + it 'keeps the execution flow as normal' do + parse_report - expect(parser).to have_received(:create_scanner) - expect(parser).to have_received(:create_scan) - end + expect(parser).to have_received(:create_scanner) + expect(parser).to have_received(:create_scan) end end end diff --git a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb index c83427b68ef..f6409c8b01f 100644 --- a/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb +++ b/spec/lib/gitlab/ci/parsers/security/validators/schema_validator_spec.rb @@ -3,6 +3,18 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do + let_it_be(:project) { create(:project) } + + let(:scanner) do + { + 'id' => 'gemnasium', + 'name' => 'Gemnasium', + 'version' => '2.1.0' + } + end + + let(:validator) { described_class.new(report_type, report_data, report_version, project: project, scanner: scanner) } + describe 'SUPPORTED_VERSIONS' do schema_path = Rails.root.join("lib", "gitlab", "ci", "parsers", "security", "validators", "schemas") @@ -47,48 +59,652 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Validators::SchemaValidator do end end - using RSpec::Parameterized::TableSyntax + describe '#valid?' do + subject { validator.valid? } - where(:report_type, :report_version, :expected_errors, :valid_data) do - 'sast' | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - :sast | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - :secret_detection | '10.0.0' | ['root is missing required keys: vulnerabilities'] | { 'version' => '10.0.0', 'vulnerabilities' => [] } - end + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } - with_them do - let(:validator) { described_class.new(report_type, report_data, report_version) } + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end - describe '#valid?' do - subject { validator.valid? } + it { is_expected.to be_truthy } + end - context 'when given data is invalid according to the schema' do - let(:report_data) { {} } + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end it { is_expected.to be_falsey } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'schema_validation_fails', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } - context 'when given data is valid according to the schema' do - let(:report_data) { valid_data } + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => '10.0.0', + 'vulnerabilities' => [] + } + end it { is_expected.to be_truthy } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_deprecated_schema_version', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end end - context 'when no report_version is provided' do - let(:report_version) { nil } - let(:report_data) { valid_data } + context 'and the report does not pass schema validation' do + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end - it 'does not fail' do - expect { subject }.not_to raise_error + it { is_expected.to be_falsey } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + it { is_expected.to be_truthy } end end end - describe '#errors' do - let(:report_data) { { 'version' => '10.0.0' } } + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to be_falsey } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_unsupported_schema_version', + security_report_scanner_id: 'gemnasium', + security_report_scanner_version: '2.1.0' + ) + + subject + end + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'and scanner information is empty' do + let(:scanner) { {} } + + it 'logs related information' do + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'schema_validation_fails', + security_report_scanner_id: nil, + security_report_scanner_version: nil + ) + + expect(Gitlab::AppLogger).to receive(:info).with( + message: "security report schema validation problem", + security_report_type: report_type, + security_report_version: report_version, + project_id: project.id, + security_report_failure: 'using_unsupported_schema_version', + security_report_scanner_id: nil, + security_report_scanner_version: nil + ) + + subject + end + end + + it { is_expected.to be_falsey } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to be_truthy } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + it { is_expected.to be_truthy } + end + end + end + end - subject { validator.errors } + describe '#errors' do + subject { validator.errors } - it { is_expected.to eq(expected_errors) } + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: project) + end + + let(:expected_errors) do + [ + 'root is missing required keys: vulnerabilities' + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => '10.0.0', + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report does not pass schema validation' do + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + let(:expected_errors) do + [ + "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_errors) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_errors) } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_errors) { [] } + + it { is_expected.to match_array(expected_errors) } + end + end + end + end + + describe '#deprecation_warnings' do + subject { validator.deprecation_warnings } + + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + let(:expected_deprecation_warnings) { [] } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + let(:expected_deprecation_warnings) do + [ + "Version V2.7.0 for report type dast has been deprecated, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + + context 'and the report does not pass schema validation' do + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "21.37.0" } + let(:expected_deprecation_warnings) { [] } + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + it { is_expected.to match_array(expected_deprecation_warnings) } + end + end + + describe '#warnings' do + subject { validator.warnings } + + context 'when given a supported schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::SUPPORTED_VERSIONS[report_type].last } + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: project) + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_warnings) do + [ + 'root is missing required keys: vulnerabilities' + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end + end + + context 'when given a deprecated schema version' do + let(:report_type) { :dast } + let(:report_version) { described_class::DEPRECATED_VERSIONS[report_type].last } + + context 'and the report passes schema validation' do + let(:report_data) do + { + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report does not pass schema validation' do + let(:report_data) do + { + 'version' => 'V2.7.0' + } + end + + context 'and enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + let(:expected_warnings) do + [ + "property '/version' does not match pattern: ^[0-9]+\\.[0-9]+\\.[0-9]+$", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end + end + + context 'when given an unsupported schema version' do + let(:report_type) { :dast } + let(:report_version) { "12.37.0" } + + context 'if enforce_security_report_validation is enabled' do + before do + stub_feature_flags(enforce_security_report_validation: true) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_warnings) { [] } + + it { is_expected.to match_array(expected_warnings) } + end + end + + context 'if enforce_security_report_validation is disabled' do + before do + stub_feature_flags(enforce_security_report_validation: false) + end + + context 'and the report is valid' do + let(:report_data) do + { + 'version' => report_version, + 'vulnerabilities' => [] + } + end + + let(:expected_warnings) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + + context 'and the report is invalid' do + let(:report_data) do + { + 'version' => report_version + } + end + + let(:expected_warnings) do + [ + "Version 12.37.0 for report type dast is unsupported, supported versions for this report type are: 14.0.0, 14.0.1, 14.0.2, 14.0.3, 14.0.4, 14.0.5, 14.0.6, 14.1.0, 14.1.1", + "root is missing required keys: vulnerabilities" + ] + end + + it { is_expected.to match_array(expected_warnings) } + end + end end end end diff --git a/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb new file mode 100644 index 00000000000..aa8aec2af4a --- /dev/null +++ b/spec/lib/gitlab/ci/pipeline/chain/limit/rate_limit_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe ::Gitlab::Ci::Pipeline::Chain::Limit::RateLimit, :freeze_time, :clean_gitlab_redis_rate_limiting do + let_it_be(:user) { create(:user) } + let_it_be(:namespace) { create(:namespace) } + let_it_be(:project, reload: true) { create(:project, namespace: namespace) } + + let(:save_incompleted) { false } + let(:throttle_message) do + 'Too many pipelines created in the last minute. Try again later.' + end + + let(:command) do + Gitlab::Ci::Pipeline::Chain::Command.new( + project: project, + current_user: user, + save_incompleted: save_incompleted + ) + end + + let(:pipeline) { build(:ci_pipeline, project: project, source: source) } + let(:source) { 'push' } + let(:step) { described_class.new(pipeline, command) } + + def perform(count: 2) + count.times { step.perform! } + end + + context 'when the limit is exceeded' do + before do + allow(Gitlab::ApplicationRateLimiter).to receive(:rate_limits) + .and_return(pipelines_create: { threshold: 1, interval: 1.minute }) + + stub_feature_flags(ci_throttle_pipelines_creation_dry_run: false) + end + + it 'does not persist the pipeline' do + perform + + expect(pipeline).not_to be_persisted + expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be_truthy + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha + ) + ) + + perform + end + + context 'with child pipelines' do + let(:source) { 'parent_pipeline' } + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end + + context 'when saving incompleted pipelines' do + let(:save_incompleted) { true } + + it 'does not persist the pipeline' do + perform + + expect(pipeline).not_to be_persisted + expect(pipeline.errors.added?(:base, throttle_message)).to be_truthy + end + + it 'breaks the chain' do + perform + + expect(step.break?).to be_truthy + end + end + + context 'when ci_throttle_pipelines_creation is disabled' do + before do + stub_feature_flags(ci_throttle_pipelines_creation: false) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end + + context 'when ci_throttle_pipelines_creation_dry_run is enabled' do + before do + stub_feature_flags(ci_throttle_pipelines_creation_dry_run: true) + end + + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'creates a log entry' do + expect(Gitlab::AppJsonLogger).to receive(:info).with( + a_hash_including( + class: described_class.name, + project_id: project.id, + subscription_plan: project.actual_plan_name, + commit_sha: command.sha + ) + ) + + perform + end + end + end + + context 'when the limit is not exceeded' do + it 'does not break the chain' do + perform + + expect(step.break?).to be_falsey + end + + it 'does not invalidate the pipeline' do + perform + + expect(pipeline.errors).to be_empty + end + + it 'does not log anything' do + expect(Gitlab::AppJsonLogger).not_to receive(:info) + + perform + end + end +end diff --git a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb index 8e0b032e68c..ddd0de69d79 100644 --- a/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb +++ b/spec/lib/gitlab/ci/pipeline/chain/template_usage_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::Ci::Pipeline::Chain::TemplateUsage do %w(Template-1 Template-2).each do |expected_template| expect(Gitlab::UsageDataCounters::CiTemplateUniqueCounter).to( receive(:track_unique_project_event) - .with(project_id: project.id, template: expected_template, config_source: pipeline.config_source) + .with(project: project, template: expected_template, config_source: pipeline.config_source, user: user) ) end diff --git a/spec/lib/gitlab/ci/reports/security/report_spec.rb b/spec/lib/gitlab/ci/reports/security/report_spec.rb index 4dc1eca3859..ab0efb90901 100644 --- a/spec/lib/gitlab/ci/reports/security/report_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/report_spec.rb @@ -184,6 +184,22 @@ RSpec.describe Gitlab::Ci::Reports::Security::Report do end end + describe 'warnings?' do + subject { report.warnings? } + + context 'when the report does not have any errors' do + it { is_expected.to be_falsey } + end + + context 'when the report has warnings' do + before do + report.add_warning('foo', 'bar') + end + + it { is_expected.to be_truthy } + end + end + describe '#primary_scanner_order_to' do let(:scanner_1) { build(:ci_reports_security_scanner) } let(:scanner_2) { build(:ci_reports_security_scanner) } diff --git a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb index 99f5d4723d3..eb406e01b24 100644 --- a/spec/lib/gitlab/ci/reports/security/scanner_spec.rb +++ b/spec/lib/gitlab/ci/reports/security/scanner_spec.rb @@ -109,6 +109,7 @@ RSpec.describe Gitlab::Ci::Reports::Security::Scanner do { external_id: 'gemnasium-maven', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | -1 { external_id: 'gemnasium-python', name: 'foo', vendor: 'bar' } | { external_id: 'bandit', name: 'foo', vendor: 'bar' } | 1 { external_id: 'bandit', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1 + { external_id: 'spotbugs', name: 'foo', vendor: 'bar' } | { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | -1 { external_id: 'semgrep', name: 'foo', vendor: 'bar' } | { external_id: 'unknown', name: 'foo', vendor: 'bar' } | -1 { external_id: 'gemnasium', name: 'foo', vendor: 'bar' } | { external_id: 'gemnasium', name: 'foo', vendor: nil } | 1 end diff --git a/spec/lib/gitlab/ci/runner_releases_spec.rb b/spec/lib/gitlab/ci/runner_releases_spec.rb new file mode 100644 index 00000000000..9e4a8739c0f --- /dev/null +++ b/spec/lib/gitlab/ci/runner_releases_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::RunnerReleases do + subject { described_class.instance } + + describe '#releases' do + before do + subject.reset! + + stub_application_setting(public_runner_releases_url: 'the release API URL') + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(response) } + end + + def releases + subject.releases + end + + shared_examples 'requests that follow cache status' do |validity_period| + context "almost #{validity_period.inspect} later" do + let(:followup_request_interval) { validity_period - 0.001.seconds } + + it 'returns cached releases' do + releases + + travel followup_request_interval do + expect(Gitlab::HTTP).not_to receive(:try_get) + + expect(releases).to eq(expected_result) + end + end + end + + context "after #{validity_period.inspect}" do + let(:followup_request_interval) { validity_period + 1.second } + let(:followup_response) { (response || []) + [{ 'name' => 'v14.9.2' }] } + + it 'checks new releases' do + releases + + travel followup_request_interval do + expect(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response(followup_response) } + + expect(releases).to eq((expected_result || []) + [Gitlab::VersionInfo.new(14, 9, 2)]) + end + end + end + end + + context 'when response is nil' do + let(:response) { nil } + let(:expected_result) { nil } + + it 'returns nil' do + expect(releases).to be_nil + end + + it_behaves_like 'requests that follow cache status', 5.seconds + + it 'performs exponential backoff on requests', :aggregate_failures do + start_time = Time.now.utc.change(usec: 0) + + http_call_timestamp_offsets = [] + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL') do + http_call_timestamp_offsets << Time.now.utc - start_time + mock_http_response(response) + end + + # An initial HTTP request fails + travel_to(start_time) + subject.reset! + expect(releases).to be_nil + + # Successive failed requests result in HTTP requests only after specific backoff periods + backoff_periods = [5, 10, 20, 40, 80, 160, 320, 640, 1280, 2560, 3600].map(&:seconds) + backoff_periods.each do |period| + travel(period - 1.second) + expect(releases).to be_nil + + travel 1.second + expect(releases).to be_nil + end + + expect(http_call_timestamp_offsets).to eq([0, 5, 15, 35, 75, 155, 315, 635, 1275, 2555, 5115, 8715]) + + # Finally a successful HTTP request results in releases being returned + allow(Gitlab::HTTP).to receive(:try_get).with('the release API URL').once { mock_http_response([{ 'name' => 'v14.9.1' }]) } + travel 1.hour + expect(releases).not_to be_nil + end + end + + context 'when response is not nil' do + let(:response) { [{ 'name' => 'v14.9.1' }, { 'name' => 'v14.9.0' }] } + let(:expected_result) { [Gitlab::VersionInfo.new(14, 9, 0), Gitlab::VersionInfo.new(14, 9, 1)] } + + it 'returns parsed and sorted Gitlab::VersionInfo objects' do + expect(releases).to eq(expected_result) + end + + it_behaves_like 'requests that follow cache status', 1.day + end + + def mock_http_response(response) + http_response = instance_double(HTTParty::Response) + + allow(http_response).to receive(:success?).and_return(response.present?) + allow(http_response).to receive(:parsed_response).and_return(response) + + http_response + end + end +end diff --git a/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb new file mode 100644 index 00000000000..b430da376dd --- /dev/null +++ b/spec/lib/gitlab/ci/runner_upgrade_check_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Ci::RunnerUpgradeCheck do + include StubVersion + using RSpec::Parameterized::TableSyntax + + describe '#check_runner_upgrade_status' do + subject(:result) { described_class.instance.check_runner_upgrade_status(runner_version) } + + before do + runner_releases_double = instance_double(Gitlab::Ci::RunnerReleases) + + allow(Gitlab::Ci::RunnerReleases).to receive(:instance).and_return(runner_releases_double) + allow(runner_releases_double).to receive(:releases).and_return(available_runner_releases.map { |v| ::Gitlab::VersionInfo.parse(v) }) + end + + context 'with available_runner_releases configured up to 14.1.1' do + let(:available_runner_releases) { %w[13.9.0 13.9.1 13.9.2 13.10.0 13.10.1 14.0.0 14.0.1 14.0.2 14.1.0 14.1.1 14.1.1-rc3] } + + context 'with nil runner_version' do + let(:runner_version) { nil } + + it 'raises :unknown' do + is_expected.to eq(:unknown) + end + end + + context 'with invalid runner_version' do + let(:runner_version) { 'junk' } + + it 'raises ArgumentError' do + expect { subject }.to raise_error(ArgumentError) + end + end + + context 'with Gitlab::VERSION set to 14.1.123' do + before do + stub_version('14.1.123', 'deadbeef') + + described_class.instance.reset! + end + + context 'with a runner_version that is too recent' do + let(:runner_version) { 'v14.2.0' } + + it 'returns :not_available' do + is_expected.to eq(:not_available) + end + end + end + + context 'with Gitlab::VERSION set to 14.0.123' do + before do + stub_version('14.0.123', 'deadbeef') + + described_class.instance.reset! + end + + context 'with valid params' do + where(:runner_version, :expected_result) do + 'v14.1.0-rc3' | :not_available # not available since the GitLab instance is still on 14.0.x + 'v14.1.0~beta.1574.gf6ea9389' | :not_available # suffixes are correctly handled + 'v14.1.0/1.1.0' | :not_available # suffixes are correctly handled + 'v14.1.0' | :not_available # not available since the GitLab instance is still on 14.0.x + 'v14.0.1' | :recommended # recommended upgrade since 14.0.2 is available + 'v14.0.2' | :not_available # not available since 14.0.2 is the latest 14.0.x release available + 'v13.10.1' | :available # available upgrade: 14.1.1 + 'v13.10.1~beta.1574.gf6ea9389' | :available # suffixes are correctly handled + 'v13.10.1/1.1.0' | :available # suffixes are correctly handled + 'v13.10.0' | :recommended # recommended upgrade since 13.10.1 is available + 'v13.9.2' | :recommended # recommended upgrade since backports are no longer released for this version + 'v13.9.0' | :recommended # recommended upgrade since backports are no longer released for this version + 'v13.8.1' | :recommended # recommended upgrade since build is too old (missing in records) + 'v11.4.1' | :recommended # recommended upgrade since build is too old (missing in records) + end + + with_them do + it 'returns symbol representing expected upgrade status' do + is_expected.to be_a(Symbol) + is_expected.to eq(expected_result) + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb index 78193055139..150705c1e36 100644 --- a/spec/lib/gitlab/ci/status/build/manual_spec.rb +++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb @@ -3,15 +3,27 @@ require 'spec_helper' RSpec.describe Gitlab::Ci::Status::Build::Manual do - let(:user) { create(:user) } + let_it_be(:user) { create(:user) } + let_it_be(:job) { create(:ci_build, :manual) } subject do - build = create(:ci_build, :manual) - described_class.new(Gitlab::Ci::Status::Core.new(build, user)) + described_class.new(Gitlab::Ci::Status::Core.new(job, user)) end describe '#illustration' do it { expect(subject.illustration).to include(:image, :size, :title, :content) } + + context 'when the user can trigger the job' do + before do + job.project.add_maintainer(user) + end + + it { expect(subject.illustration[:content]).to match /This job requires manual intervention to start/ } + end + + context 'when the user can not trigger the job' do + it { expect(subject.illustration[:content]).to match /This job does not run automatically and must be started manually/ } + end end describe '.matches?' do diff --git a/spec/lib/gitlab/ci/templates/MATLAB_spec.rb b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb new file mode 100644 index 00000000000..a12d69b67a6 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/MATLAB_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'MATLAB.gitlab-ci.yml' do + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('MATLAB') } + + describe 'the created pipeline' do + let_it_be(:project) { create(:project, :auto_devops, :custom_repo, files: { 'README.md' => '' }) } + + let(:user) { project.first_owner } + let(:default_branch) { 'master' } + let(:pipeline_branch) { default_branch } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_branch ) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + end + + it 'creates all jobs' do + expect(build_names).to include('command', 'test', 'test_artifacts_job') + end + end +end diff --git a/spec/lib/gitlab/ci/templates/templates_spec.rb b/spec/lib/gitlab/ci/templates/templates_spec.rb index cdda7e953d0..ca096fcecc4 100644 --- a/spec/lib/gitlab/ci/templates/templates_spec.rb +++ b/spec/lib/gitlab/ci/templates/templates_spec.rb @@ -23,7 +23,8 @@ RSpec.describe 'CI YML Templates' do exceptions = [ 'Security/DAST.gitlab-ci.yml', # DAST stage is defined inside AutoDevops yml 'Security/DAST-API.gitlab-ci.yml', # no auto-devops - 'Security/API-Fuzzing.gitlab-ci.yml' # no auto-devops + 'Security/API-Fuzzing.gitlab-ci.yml', # no auto-devops + 'ThemeKit.gitlab-ci.yml' ] context 'when including available templates in a CI YAML configuration' do diff --git a/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb new file mode 100644 index 00000000000..4708108f404 --- /dev/null +++ b/spec/lib/gitlab/ci/templates/themekit_gitlab_ci_yaml_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'ThemeKit.gitlab-ci.yml' do + before do + allow(Gitlab::Template::GitlabCiYmlTemplate).to receive(:excluded_patterns).and_return([]) + end + + subject(:template) { Gitlab::Template::GitlabCiYmlTemplate.find('ThemeKit') } + + describe 'the created pipeline' do + let(:pipeline_ref) { project.default_branch_or_main } + let(:project) { create(:project, :custom_repo, files: { 'README.md' => '' }) } + let(:user) { project.first_owner } + let(:service) { Ci::CreatePipelineService.new(project, user, ref: pipeline_ref) } + let(:pipeline) { service.execute!(:push).payload } + let(:build_names) { pipeline.builds.pluck(:name) } + + before do + stub_ci_pipeline_yaml_file(template.content) + end + + context 'on the default branch' do + it 'only creates staging deploy', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).to include('staging') + expect(build_names).not_to include('production') + end + end + + context 'on a tag' do + let(:pipeline_ref) { '1.0' } + + before do + project.repository.add_tag(user, pipeline_ref, project.default_branch_or_main) + end + + it 'only creates a production deploy', :aggregate_failures do + expect(pipeline.errors).to be_empty + expect(build_names).to include('production') + expect(build_names).not_to include('staging') + end + end + + context 'outside of the default branch' do + let(:pipeline_ref) { 'patch-1' } + + before do + project.repository.create_branch(pipeline_ref, project.default_branch_or_main) + end + + it 'has no jobs' do + expect { pipeline }.to raise_error( + Ci::CreatePipelineService::CreateError, 'No stages / jobs for this pipeline.' + ) + end + end + end +end diff --git a/spec/lib/gitlab/ci/variables/builder_spec.rb b/spec/lib/gitlab/ci/variables/builder_spec.rb index 8552a06eab3..b9aa5f7c431 100644 --- a/spec/lib/gitlab/ci/variables/builder_spec.rb +++ b/spec/lib/gitlab/ci/variables/builder_spec.rb @@ -199,6 +199,20 @@ RSpec.describe Gitlab::Ci::Variables::Builder do 'O' => '15', 'P' => '15') end end + + context 'with schedule variables' do + let_it_be(:schedule) { create(:ci_pipeline_schedule, project: project) } + let_it_be(:schedule_variable) { create(:ci_pipeline_schedule_variable, pipeline_schedule: schedule) } + + before do + pipeline.update!(pipeline_schedule_id: schedule.id) + end + + it 'includes schedule variables' do + expect(subject.to_runner_variables) + .to include(a_hash_including(key: schedule_variable.key, value: schedule_variable.value)) + end + end end describe '#user_variables' do @@ -278,6 +292,14 @@ RSpec.describe Gitlab::Ci::Variables::Builder do end shared_examples "secret CI variables" do + let(:protected_variable_item) do + Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) + end + + let(:unprotected_variable_item) do + Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) + end + context 'when ref is branch' do context 'when ref is protected' do before do @@ -338,189 +360,255 @@ RSpec.describe Gitlab::Ci::Variables::Builder do let_it_be(:protected_variable) { create(:ci_instance_variable, protected: true) } let_it_be(:unprotected_variable) { create(:ci_instance_variable, protected: false) } - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } - include_examples "secret CI variables" end describe '#secret_group_variables' do - subject { builder.secret_group_variables(ref: job.git_ref, environment: job.expanded_environment_name) } + subject { builder.secret_group_variables(environment: job.expanded_environment_name) } let_it_be(:protected_variable) { create(:ci_group_variable, protected: true, group: group) } let_it_be(:unprotected_variable) { create(:ci_group_variable, protected: false, group: group) } - context 'with ci_variables_builder_memoize_secret_variables disabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: false) + include_examples "secret CI variables" + + context 'variables memoization' do + let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') } + + let(:environment) { job.expanded_environment_name } + let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + + context 'with protected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(true) + + expect_next_instance_of(described_class::Group) do |group_variables_builder| + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: 'production', protected_ref: true) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_group_variables(environment: 'production')) + .to contain_exactly(unprotected_variable_item, protected_variable_item) + end + end end - let(:protected_variable_item) { protected_variable } - let(:unprotected_variable_item) { unprotected_variable } + context 'with unprotected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(false) + + expect_next_instance_of(described_class::Group) do |group_variables_builder| + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: nil, protected_ref: false) + .once + .and_call_original + + expect(group_variables_builder) + .to receive(:secret_variables) + .with(environment: 'scoped', protected_ref: false) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_group_variables(environment: nil)) + .to contain_exactly(unprotected_variable_item) - include_examples "secret CI variables" + expect(builder.secret_group_variables(environment: 'scoped')) + .to contain_exactly(unprotected_variable_item, scoped_variable_item) + end + end + end end + end - context 'with ci_variables_builder_memoize_secret_variables enabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: true) - end + describe '#secret_project_variables' do + let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) } + let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) } - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } + let(:environment) { job.expanded_environment_name } - include_examples "secret CI variables" + subject { builder.secret_project_variables(environment: environment) } - context 'variables memoization' do - let_it_be(:scoped_variable) { create(:ci_group_variable, group: group, environment_scope: 'scoped') } + include_examples "secret CI variables" - let(:ref) { job.git_ref } - let(:environment) { job.expanded_environment_name } - let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + context 'variables memoization' do + let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') } - context 'with protected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(true) + let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } - expect_next_instance_of(described_class::Group) do |group_variables_builder| - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: 'production', protected_ref: true) - .once - .and_call_original - end + context 'with protected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(true) - 2.times do - expect(builder.secret_group_variables(ref: ref, environment: 'production')) - .to contain_exactly(unprotected_variable_item, protected_variable_item) - end + expect_next_instance_of(described_class::Project) do |project_variables_builder| + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: 'production', protected_ref: true) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_project_variables(environment: 'production')) + .to contain_exactly(unprotected_variable_item, protected_variable_item) end end + end - context 'with unprotected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(false) - - expect_next_instance_of(described_class::Group) do |group_variables_builder| - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: nil, protected_ref: false) - .once - .and_call_original - - expect(group_variables_builder) - .to receive(:secret_variables) - .with(environment: 'scoped', protected_ref: false) - .once - .and_call_original - end - - 2.times do - expect(builder.secret_group_variables(ref: 'other', environment: nil)) - .to contain_exactly(unprotected_variable_item) - - expect(builder.secret_group_variables(ref: 'other', environment: 'scoped')) - .to contain_exactly(unprotected_variable_item, scoped_variable_item) - end + context 'with unprotected environments' do + it 'memoizes the result by environment' do + expect(pipeline.project) + .to receive(:protected_for?) + .with(pipeline.jobs_git_ref) + .once.and_return(false) + + expect_next_instance_of(described_class::Project) do |project_variables_builder| + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: nil, protected_ref: false) + .once + .and_call_original + + expect(project_variables_builder) + .to receive(:secret_variables) + .with(environment: 'scoped', protected_ref: false) + .once + .and_call_original + end + + 2.times do + expect(builder.secret_project_variables(environment: nil)) + .to contain_exactly(unprotected_variable_item) + + expect(builder.secret_project_variables(environment: 'scoped')) + .to contain_exactly(unprotected_variable_item, scoped_variable_item) end end end end end - describe '#secret_project_variables' do - let_it_be(:protected_variable) { create(:ci_variable, protected: true, project: project) } - let_it_be(:unprotected_variable) { create(:ci_variable, protected: false, project: project) } + describe '#config_variables' do + subject(:config_variables) { builder.config_variables } - let(:ref) { job.git_ref } - let(:environment) { job.expanded_environment_name } + context 'without project' do + before do + pipeline.update!(project_id: nil) + end + + it { expect(config_variables.size).to eq(0) } + end - subject { builder.secret_project_variables(ref: ref, environment: environment) } + context 'without repository' do + let(:project) { create(:project) } + let(:pipeline) { build(:ci_pipeline, ref: nil, sha: nil, project: project) } - context 'with ci_variables_builder_memoize_secret_variables disabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: false) + it { expect(config_variables['CI_COMMIT_SHA']).to be_nil } + end + + context 'with protected variables' do + let_it_be(:instance_variable) do + create(:ci_instance_variable, :protected, key: 'instance_variable') + end + + let_it_be(:group_variable) do + create(:ci_group_variable, :protected, group: group, key: 'group_variable') end - let(:protected_variable_item) { protected_variable } - let(:unprotected_variable_item) { unprotected_variable } + let_it_be(:project_variable) do + create(:ci_variable, :protected, project: project, key: 'project_variable') + end - include_examples "secret CI variables" + it 'does not include protected variables' do + expect(config_variables[instance_variable.key]).to be_nil + expect(config_variables[group_variable.key]).to be_nil + expect(config_variables[project_variable.key]).to be_nil + end end - context 'with ci_variables_builder_memoize_secret_variables enabled' do - before do - stub_feature_flags(ci_variables_builder_memoize_secret_variables: true) + context 'with scoped variables' do + let_it_be(:scoped_group_variable) do + create(:ci_group_variable, + group: group, + key: 'group_variable', + value: 'scoped', + environment_scope: 'scoped') end - let(:protected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(protected_variable) } - let(:unprotected_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(unprotected_variable) } + let_it_be(:group_variable) do + create(:ci_group_variable, + group: group, + key: 'group_variable', + value: 'unscoped') + end - include_examples "secret CI variables" + let_it_be(:scoped_project_variable) do + create(:ci_variable, + project: project, + key: 'project_variable', + value: 'scoped', + environment_scope: 'scoped') + end - context 'variables memoization' do - let_it_be(:scoped_variable) { create(:ci_variable, project: project, environment_scope: 'scoped') } + let_it_be(:project_variable) do + create(:ci_variable, + project: project, + key: 'project_variable', + value: 'unscoped') + end - let(:scoped_variable_item) { Gitlab::Ci::Variables::Collection::Item.fabricate(scoped_variable) } + it 'does not include scoped variables' do + expect(config_variables.to_hash[group_variable.key]).to eq('unscoped') + expect(config_variables.to_hash[project_variable.key]).to eq('unscoped') + end + end - context 'with protected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(true) + context 'variables ordering' do + def var(name, value) + { key: name, value: value.to_s, public: true, masked: false } + end - expect_next_instance_of(described_class::Project) do |project_variables_builder| - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: 'production', protected_ref: true) - .once - .and_call_original - end + before do + allow(pipeline.project).to receive(:predefined_variables) { [var('A', 1), var('B', 1)] } + allow(pipeline).to receive(:predefined_variables) { [var('B', 2), var('C', 2)] } + allow(builder).to receive(:secret_instance_variables) { [var('C', 3), var('D', 3)] } + allow(builder).to receive(:secret_group_variables) { [var('D', 4), var('E', 4)] } + allow(builder).to receive(:secret_project_variables) { [var('E', 5), var('F', 5)] } + allow(pipeline).to receive(:variables) { [var('F', 6), var('G', 6)] } + allow(pipeline).to receive(:pipeline_schedule) { double(job_variables: [var('G', 7), var('H', 7)]) } + end - 2.times do - expect(builder.secret_project_variables(ref: ref, environment: 'production')) - .to contain_exactly(unprotected_variable_item, protected_variable_item) - end - end - end + it 'returns variables in order depending on resource hierarchy' do + expect(config_variables.to_runner_variables).to eq( + [var('A', 1), var('B', 1), + var('B', 2), var('C', 2), + var('C', 3), var('D', 3), + var('D', 4), var('E', 4), + var('E', 5), var('F', 5), + var('F', 6), var('G', 6), + var('G', 7), var('H', 7)]) + end - context 'with unprotected environments' do - it 'memoizes the result by environment' do - expect(pipeline.project) - .to receive(:protected_for?) - .with(pipeline.jobs_git_ref) - .once.and_return(false) - - expect_next_instance_of(described_class::Project) do |project_variables_builder| - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: nil, protected_ref: false) - .once - .and_call_original - - expect(project_variables_builder) - .to receive(:secret_variables) - .with(environment: 'scoped', protected_ref: false) - .once - .and_call_original - end - - 2.times do - expect(builder.secret_project_variables(ref: 'other', environment: nil)) - .to contain_exactly(unprotected_variable_item) - - expect(builder.secret_project_variables(ref: 'other', environment: 'scoped')) - .to contain_exactly(unprotected_variable_item, scoped_variable_item) - end - end - end + it 'overrides duplicate keys depending on resource hierarchy' do + expect(config_variables.to_hash).to match( + 'A' => '1', 'B' => '2', + 'C' => '3', 'D' => '4', + 'E' => '5', 'F' => '6', + 'G' => '7', 'H' => '7') end end end diff --git a/spec/lib/gitlab/ci/yaml_processor_spec.rb b/spec/lib/gitlab/ci/yaml_processor_spec.rb index ebb5c91ebad..9b68ee2d6a2 100644 --- a/spec/lib/gitlab/ci/yaml_processor_spec.rb +++ b/spec/lib/gitlab/ci/yaml_processor_spec.rb @@ -842,7 +842,7 @@ module Gitlab describe "Image and service handling" do context "when extended docker configuration is used" do it "returns image and service when defined" do - config = YAML.dump({ image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] }, + config = YAML.dump({ image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: ["mysql", { name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }], @@ -860,7 +860,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7", entrypoint: ["/usr/local/bin/init", "run"] }, + image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "mysql" }, { name: "docker:dind", alias: "docker", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }] @@ -874,10 +874,10 @@ module Gitlab end it "returns image and service when overridden for job" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql"], before_script: ["pwd"], - rspec: { image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] }, + rspec: { image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }, "docker:dind"], @@ -894,7 +894,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:3.0", entrypoint: ["/usr/local/bin/init", "run"] }, + image: { name: "image:1.0", entrypoint: ["/usr/local/bin/init", "run"] }, services: [{ name: "postgresql", alias: "db-pg", entrypoint: ["/usr/local/bin/init", "run"], command: ["/usr/local/bin/init", "run"] }, { name: "docker:dind" }] @@ -910,7 +910,7 @@ module Gitlab context "when etended docker configuration is not used" do it "returns image and service when defined" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql", "docker:dind"], before_script: ["pwd"], rspec: { script: "rspec" } }) @@ -926,7 +926,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "mysql" }, { name: "docker:dind" }] }, allow_failure: false, @@ -938,10 +938,10 @@ module Gitlab end it "returns image and service when overridden for job" do - config = YAML.dump({ image: "ruby:2.7", + config = YAML.dump({ image: "image:1.0", services: ["mysql"], before_script: ["pwd"], - rspec: { image: "ruby:3.0", services: ["postgresql", "docker:dind"], script: "rspec" } }) + rspec: { image: "image:1.0", services: ["postgresql", "docker:dind"], script: "rspec" } }) config_processor = Gitlab::Ci::YamlProcessor.new(config).execute @@ -954,7 +954,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:3.0" }, + image: { name: "image:1.0" }, services: [{ name: "postgresql" }, { name: "docker:dind" }] }, allow_failure: false, @@ -1557,7 +1557,7 @@ module Gitlab describe "Artifacts" do it "returns artifacts when defined" do config = YAML.dump({ - image: "ruby:2.7", + image: "image:1.0", services: ["mysql"], before_script: ["pwd"], rspec: { @@ -1583,7 +1583,7 @@ module Gitlab options: { before_script: ["pwd"], script: ["rspec"], - image: { name: "ruby:2.7" }, + image: { name: "image:1.0" }, services: [{ name: "mysql" }], artifacts: { name: "custom_name", @@ -2327,7 +2327,7 @@ module Gitlab context 'when hidden job have a script definition' do let(:config) do YAML.dump({ - '.hidden_job' => { image: 'ruby:2.7', script: 'test' }, + '.hidden_job' => { image: 'image:1.0', script: 'test' }, 'normal_job' => { script: 'test' } }) end @@ -2338,7 +2338,7 @@ module Gitlab context "when hidden job doesn't have a script definition" do let(:config) do YAML.dump({ - '.hidden_job' => { image: 'ruby:2.7' }, + '.hidden_job' => { image: 'image:1.0' }, 'normal_job' => { script: 'test' } }) end diff --git a/spec/lib/gitlab/config/loader/yaml_spec.rb b/spec/lib/gitlab/config/loader/yaml_spec.rb index be568a8e5f9..66ea931a42c 100644 --- a/spec/lib/gitlab/config/loader/yaml_spec.rb +++ b/spec/lib/gitlab/config/loader/yaml_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do let(:yml) do <<~YAML - image: 'ruby:2.7' + image: 'image:1.0' texts: nested_key: 'value1' more_text: @@ -34,7 +34,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do end context 'when yaml syntax is correct' do - let(:yml) { 'image: ruby:2.7' } + let(:yml) { 'image: image:1.0' } describe '#valid?' do it 'returns true' do @@ -44,7 +44,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load!' do it 'returns a valid hash' do - expect(loader.load!).to eq(image: 'ruby:2.7') + expect(loader.load!).to eq(image: 'image:1.0') end end end @@ -164,7 +164,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load_raw!' do it 'loads keys as strings' do expect(loader.load_raw!).to eq( - 'image' => 'ruby:2.7', + 'image' => 'image:1.0', 'texts' => { 'nested_key' => 'value1', 'more_text' => { @@ -178,7 +178,7 @@ RSpec.describe Gitlab::Config::Loader::Yaml do describe '#load!' do it 'symbolizes keys' do expect(loader.load!).to eq( - image: 'ruby:2.7', + image: 'image:1.0', texts: { nested_key: 'value1', more_text: { diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index 08d29f7842c..44e2cb21677 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -107,24 +107,8 @@ RSpec.describe Gitlab::ContentSecurityPolicy::ConfigLoader do stub_env('CUSTOMER_PORTAL_URL', customer_portal_url) end - context 'when in production' do - before do - allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) - end - - it 'does not add CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") - end - end - - context 'when in development' do - before do - allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) - end - - it 'adds CUSTOMER_PORTAL_URL to CSP' do - expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/rails/letter_opener/ https://customers.example.com http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid") - end + it 'adds CUSTOMER_PORTAL_URL to CSP' do + expect(directives['frame_src']).to eq(::Gitlab::ContentSecurityPolicy::Directives.frame_src + " http://localhost/admin/ http://localhost/assets/ http://localhost/-/speedscope/index.html http://localhost/-/sandbox/mermaid #{customer_portal_url}") end end diff --git a/spec/lib/gitlab/data_builder/deployment_spec.rb b/spec/lib/gitlab/data_builder/deployment_spec.rb index ab8c8a51694..e8fe80f75cb 100644 --- a/spec/lib/gitlab/data_builder/deployment_spec.rb +++ b/spec/lib/gitlab/data_builder/deployment_spec.rb @@ -46,5 +46,42 @@ RSpec.describe Gitlab::DataBuilder::Deployment do expect(data[:deployable_url]).to be_nil end + + context 'when commit does not exist in the repository' do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:deployment) { create(:deployment, project: project) } + + subject(:data) { described_class.build(deployment, Time.current) } + + before(:all) do + project.repository.remove + end + + it 'returns nil for commit_url' do + expect(data[:commit_url]).to be_nil + end + + it 'returns nil for commit_title' do + expect(data[:commit_title]).to be_nil + end + end + + context 'when deployed_by is nil' do + let_it_be(:deployment) { create(:deployment, user: nil, deployable: nil) } + + subject(:data) { described_class.build(deployment, Time.current) } + + before(:all) do + deployment.user = nil + end + + it 'returns nil for user' do + expect(data[:user]).to be_nil + end + + it 'returns nil for user_url' do + expect(data[:user_url]).to be_nil + end + end end end diff --git a/spec/lib/gitlab/data_builder/note_spec.rb b/spec/lib/gitlab/data_builder/note_spec.rb index 90ca5430526..3fa535dd800 100644 --- a/spec/lib/gitlab/data_builder/note_spec.rb +++ b/spec/lib/gitlab/data_builder/note_spec.rb @@ -8,18 +8,22 @@ RSpec.describe Gitlab::DataBuilder::Note do let(:data) { described_class.build(note, user) } let(:fixed_time) { Time.at(1425600000) } # Avoid time precision errors - before do - expect(data).to have_key(:object_attributes) - expect(data[:object_attributes]).to have_key(:url) - expect(data[:object_attributes][:url]) - .to eq(Gitlab::UrlBuilder.build(note)) - expect(data[:object_kind]).to eq('note') - expect(data[:user]).to eq(user.hook_attrs) + shared_examples 'includes general data' do + specify do + expect(data).to have_key(:object_attributes) + expect(data[:object_attributes]).to have_key(:url) + expect(data[:object_attributes][:url]) + .to eq(Gitlab::UrlBuilder.build(note)) + expect(data[:object_kind]).to eq('note') + expect(data[:user]).to eq(user.hook_attrs) + end end describe 'When asking for a note on commit' do let(:note) { create(:note_on_commit, project: project) } + it_behaves_like 'includes general data' + it 'returns the note and commit-specific data' do expect(data).to have_key(:commit) end @@ -31,6 +35,8 @@ RSpec.describe Gitlab::DataBuilder::Note do describe 'When asking for a note on commit diff' do let(:note) { create(:diff_note_on_commit, project: project) } + it_behaves_like 'includes general data' + it 'returns the note and commit-specific data' do expect(data).to have_key(:commit) end @@ -51,22 +57,21 @@ RSpec.describe Gitlab::DataBuilder::Note do create(:note_on_issue, noteable: issue, project: project) end + it_behaves_like 'includes general data' + it 'returns the note and issue-specific data' do - without_timestamps = lambda { |label| label.except('created_at', 'updated_at') } - hook_attrs = issue.reload.hook_attrs + expect_next_instance_of(Gitlab::HookData::IssueBuilder) do |issue_data_builder| + expect(issue_data_builder).to receive(:build).and_return('Issue data') + end - expect(data).to have_key(:issue) - expect(data[:issue].except('updated_at', 'labels')) - .to eq(hook_attrs.except('updated_at', 'labels')) - expect(data[:issue]['updated_at']) - .to be >= hook_attrs['updated_at'] - expect(data[:issue]['labels'].map(&without_timestamps)) - .to eq(hook_attrs['labels'].map(&without_timestamps)) + expect(data[:issue]).to eq('Issue data') end context 'with confidential issue' do let(:issue) { create(:issue, project: project, confidential: true) } + it_behaves_like 'includes general data' + it 'sets event_type to confidential_note' do expect(data[:event_type]).to eq('confidential_note') end @@ -77,10 +82,12 @@ RSpec.describe Gitlab::DataBuilder::Note do end describe 'When asking for a note on merge request' do + let(:label) { create(:label, project: project) } let(:merge_request) do - create(:merge_request, created_at: fixed_time, + create(:labeled_merge_request, created_at: fixed_time, updated_at: fixed_time, - source_project: project) + source_project: project, + labels: [label]) end let(:note) do @@ -88,12 +95,14 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end - it 'returns the note and merge request data' do - expect(data).to have_key(:merge_request) - expect(data[:merge_request].except('updated_at')) - .to eq(merge_request.reload.hook_attrs.except('updated_at')) - expect(data[:merge_request]['updated_at']) - .to be >= merge_request.hook_attrs['updated_at'] + it_behaves_like 'includes general data' + + it 'returns the merge request data' do + expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder| + expect(mr_data_builder).to receive(:build).and_return('MR data') + end + + expect(data[:merge_request]).to eq('MR data') end include_examples 'project hook data' @@ -101,9 +110,11 @@ RSpec.describe Gitlab::DataBuilder::Note do end describe 'When asking for a note on merge request diff' do + let(:label) { create(:label, project: project) } let(:merge_request) do - create(:merge_request, created_at: fixed_time, updated_at: fixed_time, - source_project: project) + create(:labeled_merge_request, created_at: fixed_time, updated_at: fixed_time, + source_project: project, + labels: [label]) end let(:note) do @@ -111,12 +122,14 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end - it 'returns the note and merge request diff data' do - expect(data).to have_key(:merge_request) - expect(data[:merge_request].except('updated_at')) - .to eq(merge_request.reload.hook_attrs.except('updated_at')) - expect(data[:merge_request]['updated_at']) - .to be >= merge_request.hook_attrs['updated_at'] + it_behaves_like 'includes general data' + + it 'returns the merge request data' do + expect_next_instance_of(Gitlab::HookData::MergeRequestBuilder) do |mr_data_builder| + expect(mr_data_builder).to receive(:build).and_return('MR data') + end + + expect(data[:merge_request]).to eq('MR data') end include_examples 'project hook data' @@ -134,6 +147,8 @@ RSpec.describe Gitlab::DataBuilder::Note do project: project) end + it_behaves_like 'includes general data' + it 'returns the note and project snippet data' do expect(data).to have_key(:snippet) expect(data[:snippet].except('updated_at')) diff --git a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb index 66983733411..6db3081ca7e 100644 --- a/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batch_metrics_spec.rb @@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do expect(batch_metrics.timings).to be_empty expect(Gitlab::Metrics::System).to receive(:monotonic_time) - .exactly(6).times .and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0) batch_metrics.time_operation(:my_label) do @@ -28,4 +27,33 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchMetrics do expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0]) end end + + describe '#instrument_operation' do + it 'tracks duration and affected rows' do + expect(batch_metrics.timings).to be_empty + expect(batch_metrics.affected_rows).to be_empty + + expect(Gitlab::Metrics::System).to receive(:monotonic_time) + .and_return(0.0, 111.0, 200.0, 290.0, 300.0, 410.0, 420.0, 450.0) + + batch_metrics.instrument_operation(:my_label) do + 3 + end + + batch_metrics.instrument_operation(:my_other_label) do + 42 + end + + batch_metrics.instrument_operation(:my_label) do + 2 + end + + batch_metrics.instrument_operation(:my_other_label) do + :not_an_integer + end + + expect(batch_metrics.timings).to eq(my_label: [111.0, 110.0], my_other_label: [90.0, 30.0]) + expect(batch_metrics.affected_rows).to eq(my_label: [3, 2], my_other_label: [42]) + end + end end diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb index 8c663ff9f8a..c39f6a78e93 100644 --- a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb @@ -21,7 +21,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d context 'when a job is running' do it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :running, previous_state: :failed } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: nil, + exception_message: nil, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :running, + previous_state: :failed + } + ) expect { job.run! }.to change(job, :started_at) end @@ -31,7 +43,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d let(:job) { create(:batched_background_migration_job, :running) } it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :succeeded, previous_state: :running } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: nil, + exception_message: nil, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :succeeded, + previous_state: :running + } + ) job.succeed! end @@ -89,7 +113,15 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d end it 'logs the error' do - expect(Gitlab::AppLogger).to receive(:error).with( { message: error_message, batched_job_id: job.id } ) + expect(Gitlab::AppLogger).to receive(:error).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: error_message + } + ) job.failure!(error: exception) end @@ -100,13 +132,32 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d let(:job) { create(:batched_background_migration_job, :running) } it 'logs the transition' do - expect(Gitlab::AppLogger).to receive(:info).with( { batched_job_id: job.id, message: 'BatchedJob transition', new_state: :failed, previous_state: :running } ) + expect(Gitlab::AppLogger).to receive(:info).with( + { + batched_job_id: job.id, + batched_migration_id: job.batched_background_migration_id, + exception_class: RuntimeError, + exception_message: 'error', + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name, + message: 'BatchedJob transition', + new_state: :failed, + previous_state: :running + } + ) - job.failure! + job.failure!(error: RuntimeError.new('error')) end it 'tracks the exception' do - expect(Gitlab::ErrorTracking).to receive(:track_exception).with(RuntimeError, { batched_job_id: job.id } ) + expect(Gitlab::ErrorTracking).to receive(:track_exception).with( + RuntimeError, + { + batched_job_id: job.id, + job_arguments: job.batched_migration.job_arguments, + job_class_name: job.batched_migration.job_class_name + } + ) job.failure!(error: RuntimeError.new) end @@ -130,13 +181,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d describe 'scopes' do let_it_be(:fixed_time) { Time.new(2021, 04, 27, 10, 00, 00, 00) } - let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time) } - let_it_be(:running_job) { create(:batched_background_migration_job, :running, updated_at: fixed_time) } - let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) } - let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, attempts: 1) } - - let!(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, attempts: described_class::MAX_ATTEMPTS) } - let!(:succeeded_job) { create(:batched_background_migration_job, :succeeded) } + let_it_be(:pending_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time - 2.days, updated_at: fixed_time) } + let_it_be(:running_job) { create(:batched_background_migration_job, :running, created_at: fixed_time - 2.days, updated_at: fixed_time) } + let_it_be(:stuck_job) { create(:batched_background_migration_job, :pending, created_at: fixed_time, updated_at: fixed_time - described_class::STUCK_JOBS_TIMEOUT) } + let_it_be(:failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: 1) } + let_it_be(:max_attempts_failed_job) { create(:batched_background_migration_job, :failed, created_at: fixed_time, attempts: described_class::MAX_ATTEMPTS) } before do travel_to fixed_time @@ -165,6 +214,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d expect(described_class.retriable).to contain_exactly(failed_job, stuck_job) end end + + describe '.created_since' do + it 'returns jobs since a given time' do + expect(described_class.created_since(fixed_time)).to contain_exactly(stuck_job, failed_job, max_attempts_failed_job) + end + end end describe 'delegated batched_migration attributes' do @@ -194,6 +249,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d expect(batched_job.migration_job_arguments).to eq(batched_migration.job_arguments) end end + + describe '#migration_job_class_name' do + it 'returns the migration job_class_name' do + expect(batched_job.migration_job_class_name).to eq(batched_migration.job_class_name) + end + end end describe '#can_split?' do diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb index 124d204cb62..f147e8204e6 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb @@ -3,8 +3,16 @@ require 'spec_helper' RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do + let(:connection) { Gitlab::Database.database_base_models[:main].connection } let(:migration_wrapper) { double('test wrapper') } - let(:runner) { described_class.new(migration_wrapper) } + + let(:runner) { described_class.new(connection: connection, migration_wrapper: migration_wrapper) } + + around do |example| + Gitlab::Database::SharedModel.using_connection(connection) do + example.run + end + end describe '#run_migration_job' do shared_examples_for 'it has completed the migration' do @@ -86,6 +94,19 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do end end + context 'when the migration should stop' do + let(:migration) { create(:batched_background_migration, :active) } + + let!(:job) { create(:batched_background_migration_job, :failed, batched_migration: migration) } + + it 'changes the status to failure' do + expect(migration).to receive(:should_stop?).and_return(true) + expect(migration_wrapper).to receive(:perform).and_return(job) + + expect { runner.run_migration_job(migration) }.to change { migration.status_name }.from(:active).to(:failed) + end + end + context 'when the migration has previous jobs' do let!(:event1) { create(:event) } let!(:event2) { create(:event) } @@ -282,7 +303,9 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do end describe '#finalize' do - let(:migration_wrapper) { Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new } + let(:migration_wrapper) do + Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper.new(connection: connection) + end let(:migration_helpers) { ActiveRecord::Migration.new } let(:table_name) { :_test_batched_migrations_test_table } @@ -293,8 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do let!(:batched_migration) do create( - :batched_background_migration, - status: migration_status, + :batched_background_migration, migration_status, max_value: 8, batch_size: 2, sub_batch_size: 1, @@ -339,7 +361,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do .with('CopyColumnUsingBackgroundMigrationJob', table_name, column_name, job_arguments) .and_return(batched_migration) - expect(batched_migration).to receive(:finalizing!).and_call_original + expect(batched_migration).to receive(:finalize!).and_call_original expect do runner.finalize( @@ -348,7 +370,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do column_name, job_arguments ) - end.to change { batched_migration.reload.status }.from('active').to('finished') + end.to change { batched_migration.reload.status_name }.from(:active).to(:finished) expect(batched_migration.batched_jobs).to all(be_succeeded) @@ -390,7 +412,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do expect(Gitlab::AppLogger).to receive(:warn) .with("Batched background migration for the given configuration is already finished: #{configuration}") - expect(batched_migration).not_to receive(:finalizing!) + expect(batched_migration).not_to receive(:finalize!) runner.finalize( batched_migration.job_class_name, @@ -417,7 +439,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do expect(Gitlab::AppLogger).to receive(:warn) .with("Could not find batched background migration for the given configuration: #{configuration}") - expect(batched_migration).not_to receive(:finalizing!) + expect(batched_migration).not_to receive(:finalize!) runner.finalize( batched_migration.job_class_name, @@ -431,8 +453,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do describe '.finalize' do context 'when the connection is passed' do - let(:connection) { double('connection') } - let(:table_name) { :_test_batched_migrations_test_table } let(:column_name) { :some_id } let(:job_arguments) { [:some, :other, :arguments] } diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb index 803123e8e34..7a433be0e2f 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb @@ -27,28 +27,46 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it { is_expected.to validate_uniqueness_of(:job_arguments).scoped_to(:job_class_name, :table_name, :column_name) } context 'when there are failed jobs' do - let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) } let!(:batched_job) { create(:batched_background_migration_job, :failed, batched_migration: batched_migration) } it 'raises an exception' do - expect { batched_migration.finished! }.to raise_error(ActiveRecord::RecordInvalid) + expect { batched_migration.finish! }.to raise_error(StateMachines::InvalidTransition) - expect(batched_migration.reload.status).to eql 'active' + expect(batched_migration.reload.status_name).to be :active end end context 'when the jobs are completed' do - let(:batched_migration) { create(:batched_background_migration, status: :active, total_tuple_count: 100) } + let(:batched_migration) { create(:batched_background_migration, :active, total_tuple_count: 100) } let!(:batched_job) { create(:batched_background_migration_job, :succeeded, batched_migration: batched_migration) } it 'finishes the migration' do - batched_migration.finished! + batched_migration.finish! - expect(batched_migration.status).to eql 'finished' + expect(batched_migration.status_name).to be :finished end end end + describe 'state machine' do + context 'when a migration is executed' do + let!(:batched_migration) { create(:batched_background_migration) } + + it 'updates the started_at' do + expect { batched_migration.execute! }.to change(batched_migration, :started_at).from(nil).to(Time) + end + end + end + + describe '.valid_status' do + valid_status = [:paused, :active, :finished, :failed, :finalizing] + + it 'returns valid status' do + expect(described_class.valid_status).to eq(valid_status) + end + end + describe '.queue_order' do let!(:migration1) { create(:batched_background_migration) } let!(:migration2) { create(:batched_background_migration) } @@ -61,12 +79,23 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m describe '.active_migration' do let!(:migration1) { create(:batched_background_migration, :finished) } - let!(:migration2) { create(:batched_background_migration, :active) } - let!(:migration3) { create(:batched_background_migration, :active) } - it 'returns the first active migration according to queue order' do - expect(described_class.active_migration).to eq(migration2) - create(:batched_background_migration_job, :succeeded, batched_migration: migration1, batch_size: 1000) + context 'without migrations on hold' do + let!(:migration2) { create(:batched_background_migration, :active) } + let!(:migration3) { create(:batched_background_migration, :active) } + + it 'returns the first active migration according to queue order' do + expect(described_class.active_migration).to eq(migration2) + end + end + + context 'with migrations are on hold' do + let!(:migration2) { create(:batched_background_migration, :active, on_hold_until: 10.minutes.from_now) } + let!(:migration3) { create(:batched_background_migration, :active, on_hold_until: 2.minutes.ago) } + + it 'returns the first active migration that is not on hold according to queue order' do + expect(described_class.active_migration).to eq(migration3) + end end end @@ -287,7 +316,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it 'moves the status of the migration to active' do retry_failed_jobs - expect(batched_migration.status).to eql 'active' + expect(batched_migration.status_name).to be :active end it 'changes the number of attempts to 0' do @@ -301,8 +330,59 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m it 'moves the status of the migration to active' do retry_failed_jobs - expect(batched_migration.status).to eql 'active' + expect(batched_migration.status_name).to be :active + end + end + end + + describe '#should_stop?' do + subject(:should_stop?) { batched_migration.should_stop? } + + let(:batched_migration) { create(:batched_background_migration, started_at: started_at) } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 1) + end + + context 'when the started_at is nil' do + let(:started_at) { nil } + + it { expect(should_stop?).to be_falsey } + end + + context 'when the number of jobs is lesser than the MINIMUM_JOBS' do + let(:started_at) { Time.zone.now - 6.days } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MINIMUM_JOBS', 10) + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70) + create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration) + create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration) + end + + it { expect(should_stop?).to be_falsey } + end + + context 'when the calculated value is greater than the threshold' do + let(:started_at) { Time.zone.now - 6.days } + + before do + stub_const('Gitlab::Database::BackgroundMigration::BatchedMigration::MAXIMUM_FAILED_RATIO', 0.70) + create_list(:batched_background_migration_job, 1, :succeeded, batched_migration: batched_migration) + create_list(:batched_background_migration_job, 3, :failed, batched_migration: batched_migration) + end + + it { expect(should_stop?).to be_truthy } + end + + context 'when the calculated value is lesser than the threshold' do + let(:started_at) { Time.zone.now - 6.days } + + before do + create_list(:batched_background_migration_job, 2, :succeeded, batched_migration: batched_migration) end + + it { expect(should_stop?).to be_falsey } end end @@ -449,6 +529,20 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m end end + describe '#hold!', :freeze_time do + subject { create(:batched_background_migration) } + + let(:time) { 5.minutes.from_now } + + it 'updates on_hold_until property' do + expect { subject.hold!(until_time: time) }.to change { subject.on_hold_until }.from(nil).to(time) + end + + it 'defaults to 10 minutes' do + expect { subject.hold! }.to change { subject.on_hold_until }.from(nil).to(10.minutes.from_now) + end + end + describe '.for_configuration' do let!(:migration) do create( diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb index d6c984c7adb..6a4ac317cad 100644 --- a/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb +++ b/spec/lib/gitlab/database/background_migration/batched_migration_wrapper_spec.rb @@ -3,8 +3,10 @@ require 'spec_helper' RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, '#perform' do - subject { described_class.new.perform(job_record) } + subject { described_class.new(connection: connection, metrics: metrics_tracker).perform(job_record) } + let(:connection) { Gitlab::Database.database_base_models[:main].connection } + let(:metrics_tracker) { instance_double('::Gitlab::Database::BackgroundMigration::PrometheusMetrics', track: nil) } let(:job_class) { Gitlab::BackgroundMigration::CopyColumnUsingBackgroundMigrationJob } let_it_be(:pause_ms) { 250 } @@ -19,6 +21,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' let(:job_instance) { double('job instance', batch_metrics: {}) } + around do |example| + Gitlab::Database::SharedModel.using_connection(connection) do + example.run + end + end + before do allow(job_class).to receive(:new).and_return(job_instance) end @@ -78,86 +86,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' end end - context 'reporting prometheus metrics' do - let(:labels) { job_record.batched_migration.prometheus_labels } - - before do - allow(job_instance).to receive(:perform) - end - - it 'reports batch_size' do - expect(described_class.metrics[:gauge_batch_size]).to receive(:set).with(labels, job_record.batch_size) - - subject - end - - it 'reports sub_batch_size' do - expect(described_class.metrics[:gauge_sub_batch_size]).to receive(:set).with(labels, job_record.sub_batch_size) - - subject - end - - it 'reports interval' do - expect(described_class.metrics[:gauge_interval]).to receive(:set).with(labels, job_record.batched_migration.interval) - - subject - end - - it 'reports updated tuples (currently based on batch_size)' do - expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment).with(labels, job_record.batch_size) - - subject - end - - it 'reports migrated tuples' do - count = double - expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(count) - expect(described_class.metrics[:gauge_migrated_tuples]).to receive(:set).with(labels, count) - - subject - end - - it 'reports summary of query timings' do - metrics = { 'timings' => { 'update_all' => [1, 2, 3, 4, 5] } } - - expect(job_instance).to receive(:batch_metrics).and_return(metrics) - - metrics['timings'].each do |key, timings| - summary_labels = labels.merge(operation: key) - timings.each do |timing| - expect(described_class.metrics[:histogram_timings]).to receive(:observe).with(summary_labels, timing) - end - end - - subject - end - - it 'reports job duration' do - freeze_time do - expect(Time).to receive(:current).and_return(Time.zone.now - 5.seconds).ordered - allow(Time).to receive(:current).and_call_original - - expect(described_class.metrics[:gauge_job_duration]).to receive(:set).with(labels, 5.seconds) - - subject - end - end - - it 'reports the total tuple count for the migration' do - expect(described_class.metrics[:gauge_total_tuple_count]).to receive(:set).with(labels, job_record.batched_migration.total_tuple_count) - - subject - end - - it 'reports last updated at timestamp' do - freeze_time do - expect(described_class.metrics[:gauge_last_update_time]).to receive(:set).with(labels, Time.current.to_i) - - subject - end - end - end - context 'when the migration job does not raise an error' do it 'marks the tracking record as succeeded' do expect(job_instance).to receive(:perform).with(1, 10, 'events', 'id', 1, pause_ms, 'id', 'other_id') @@ -171,6 +99,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(reloaded_job_record.finished_at).to eq(Time.current) end end + + it 'tracks metrics of the execution' do + expect(job_instance).to receive(:perform) + expect(metrics_tracker).to receive(:track).with(job_record) + + subject + end end context 'when the migration job raises an error' do @@ -189,6 +124,13 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(reloaded_job_record.finished_at).to eq(Time.current) end end + + it 'tracks metrics of the execution' do + expect(job_instance).to receive(:perform).and_raise(error_class) + expect(metrics_tracker).to receive(:track).with(job_record) + + expect { subject }.to raise_error(error_class) + end end it_behaves_like 'an error is raised', RuntimeError.new('Something broke!') @@ -203,7 +145,6 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' stub_const('Gitlab::BackgroundMigration::Foo', migration_class) end - let(:connection) { double(:connection) } let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') } let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) } @@ -212,12 +153,11 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(job_instance).to receive(:perform) - described_class.new(connection: connection).perform(job_record) + subject end end context 'when the batched background migration inherits from BaseJob' do - let(:connection) { double(:connection) } let(:active_migration) { create(:batched_background_migration, :active, job_class_name: 'Foo') } let!(:job_record) { create(:batched_background_migration_job, batched_migration: active_migration) } @@ -232,7 +172,7 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationWrapper, ' expect(job_instance).to receive(:perform) - described_class.new(connection: connection).perform(job_record) + subject end end end diff --git a/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb new file mode 100644 index 00000000000..1f256de35ec --- /dev/null +++ b/spec/lib/gitlab/database/background_migration/prometheus_metrics_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::BackgroundMigration::PrometheusMetrics, :prometheus do + describe '#track' do + let(:job_record) do + build(:batched_background_migration_job, :succeeded, + started_at: Time.current - 2.minutes, + finished_at: Time.current - 1.minute, + updated_at: Time.current, + metrics: { 'timings' => { 'update_all' => [0.05, 0.2, 0.4, 0.9, 4] } }) + end + + let(:labels) { job_record.batched_migration.prometheus_labels } + + subject(:track_job_record_metrics) { described_class.new.track(job_record) } + + it 'reports batch_size' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_batch_size)).to eq(job_record.batch_size) + end + + it 'reports sub_batch_size' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_sub_batch_size)).to eq(job_record.sub_batch_size) + end + + it 'reports interval' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_interval)).to eq(job_record.batched_migration.interval) + end + + it 'reports job duration' do + freeze_time do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_job_duration)).to eq(1.minute) + end + end + + it 'increments updated tuples (currently based on batch_size)' do + expect(described_class.metrics[:counter_updated_tuples]).to receive(:increment) + .with(labels, job_record.batch_size) + .twice + .and_call_original + + track_job_record_metrics + + expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size) + + described_class.new.track(job_record) + + expect(metric_for_job_by_name(:counter_updated_tuples)).to eq(job_record.batch_size * 2) + end + + it 'reports migrated tuples' do + expect(job_record.batched_migration).to receive(:migrated_tuple_count).and_return(20) + + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_migrated_tuples)).to eq(20) + end + + it 'reports the total tuple count for the migration' do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_total_tuple_count)).to eq(job_record.batched_migration.total_tuple_count) + end + + it 'reports last updated at timestamp' do + freeze_time do + track_job_record_metrics + + expect(metric_for_job_by_name(:gauge_last_update_time)).to eq(Time.current.to_i) + end + end + + it 'reports summary of query timings' do + summary_labels = labels.merge(operation: 'update_all') + + job_record.metrics['timings']['update_all'].each do |timing| + expect(described_class.metrics[:histogram_timings]).to receive(:observe) + .with(summary_labels, timing) + .and_call_original + end + + track_job_record_metrics + + expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels)) + .to eq({ 0.1 => 1.0, 0.25 => 2.0, 0.5 => 3.0, 1 => 4.0, 5 => 5.0 }) + end + + context 'when the tracking record does not having timing metrics' do + before do + job_record.metrics = {} + end + + it 'does not attempt to report query timings' do + summary_labels = labels.merge(operation: 'update_all') + + expect(described_class.metrics[:histogram_timings]).not_to receive(:observe) + + track_job_record_metrics + + expect(metric_for_job_by_name(:histogram_timings, job_labels: summary_labels)) + .to eq({ 0.1 => 0.0, 0.25 => 0.0, 0.5 => 0.0, 1 => 0.0, 5 => 0.0 }) + end + end + + def metric_for_job_by_name(name, job_labels: labels) + described_class.metrics[name].values[job_labels].get + end + end +end diff --git a/spec/lib/gitlab/database/consistency_checker_spec.rb b/spec/lib/gitlab/database/consistency_checker_spec.rb new file mode 100644 index 00000000000..2ff79d20786 --- /dev/null +++ b/spec/lib/gitlab/database/consistency_checker_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::ConsistencyChecker do + let(:batch_size) { 10 } + let(:max_batches) { 4 } + let(:max_runtime) { described_class::MAX_RUNTIME } + let(:metrics_counter) { Gitlab::Metrics.registry.get(:consistency_checks) } + + subject(:consistency_checker) do + described_class.new( + source_model: Namespace, + target_model: Ci::NamespaceMirror, + source_columns: %w[id traversal_ids], + target_columns: %w[namespace_id traversal_ids] + ) + end + + before do + stub_const("#{described_class.name}::BATCH_SIZE", batch_size) + stub_const("#{described_class.name}::MAX_BATCHES", max_batches) + redis_shared_state_cleanup! # For Prometheus Counters + end + + after do + Gitlab::Metrics.reset_registry! + end + + describe '#over_time_limit?' do + before do + allow(consistency_checker).to receive(:start_time).and_return(0) + end + + it 'returns true only if the running time has exceeded MAX_RUNTIME' do + allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1) + expect(consistency_checker.monotonic_time).to eq(0) + expect(consistency_checker.send(:over_time_limit?)).to eq(false) + expect(consistency_checker.send(:over_time_limit?)).to eq(true) + end + end + + describe '#execute' do + context 'when empty tables' do + it 'returns an empty response' do + expected_result = { matches: 0, mismatches: 0, batches: 0, mismatches_details: [], next_start_id: nil } + expect(consistency_checker.execute(start_id: 1)).to eq(expected_result) + end + end + + context 'when the tables contain matching items' do + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + end + + it 'does not process more than MAX_BATCHES' do + max_batches = 3 + stub_const("#{described_class.name}::MAX_BATCHES", max_batches) + result = consistency_checker.execute(start_id: Namespace.minimum(:id)) + expect(result[:batches]).to eq(max_batches) + expect(result[:matches]).to eq(max_batches * batch_size) + end + + it 'doesn not exceed the MAX_RUNTIME' do + allow(consistency_checker).to receive(:monotonic_time).and_return(0, max_runtime - 1, max_runtime + 1) + result = consistency_checker.execute(start_id: Namespace.minimum(:id)) + expect(result[:batches]).to eq(1) + expect(result[:matches]).to eq(1 * batch_size) + end + + it 'returns the correct number of matches and batches checked' do + expected_result = { + next_start_id: Namespace.minimum(:id) + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: max_batches, + matches: max_batches * batch_size, + mismatches: 0, + mismatches_details: [] + } + expect(consistency_checker.execute(start_id: Namespace.minimum(:id))).to eq(expected_result) + end + + it 'returns the min_id as the next_start_id if the check reaches the last element' do + expect(Gitlab::Metrics).to receive(:counter).at_most(:once) + .with(:consistency_checks, "Consistency Check Results") + .and_call_original + + # Starting from the 5th last element + start_id = Namespace.all.order(id: :desc).limit(5).pluck(:id).last + expected_result = { + next_start_id: Namespace.first.id, + batches: 1, + matches: 5, + mismatches: 0, + mismatches_details: [] + } + expect(consistency_checker.execute(start_id: start_id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(0) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(5) + end + end + + context 'when some items are missing from the first table' do + let(:missing_namespace) { Namespace.all.order(:id).limit(2).last } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + missing_namespace.delete + end + + it 'reports the missing elements' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: max_batches, + matches: 39, + mismatches: 1, + mismatches_details: [{ + id: missing_namespace.id, + source_table: nil, + target_table: [missing_namespace.traversal_ids] + }] + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39) + end + end + + context 'when some items are missing from the second table' do + let(:missing_ci_namespace_mirror) { Ci::NamespaceMirror.all.order(:id).limit(2).last } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + missing_ci_namespace_mirror.delete + end + + it 'reports the missing elements' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: 4, + matches: 39, + mismatches: 1, + mismatches_details: [{ + id: missing_ci_namespace_mirror.namespace_id, + source_table: [missing_ci_namespace_mirror.traversal_ids], + target_table: nil + }] + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(1) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(39) + end + end + + context 'when elements are different between the two tables' do + let(:different_namespaces) { Namespace.order(:id).limit(max_batches * batch_size).sample(3).sort_by(&:id) } + + before do + create_list(:namespace, 50) # This will also create Ci::NameSpaceMirror objects + + different_namespaces.each do |namespace| + namespace.update_attribute(:traversal_ids, []) + end + end + + it 'reports the difference between the two tables' do + expected_result = { + next_start_id: Namespace.first.id + described_class::MAX_BATCHES * described_class::BATCH_SIZE, + batches: 4, + matches: 37, + mismatches: 3, + mismatches_details: different_namespaces.map do |namespace| + { + id: namespace.id, + source_table: [[]], + target_table: [[namespace.id]] # old traversal_ids of the namespace + } + end + } + expect(consistency_checker.execute(start_id: Namespace.first.id)).to eq(expected_result) + + expect(metrics_counter.get(source_table: "namespaces", result: "mismatch")).to eq(3) + expect(metrics_counter.get(source_table: "namespaces", result: "match")).to eq(37) + end + end + end +end diff --git a/spec/lib/gitlab/database/each_database_spec.rb b/spec/lib/gitlab/database/each_database_spec.rb index d46c1ca8681..191f7017b4c 100644 --- a/spec/lib/gitlab/database/each_database_spec.rb +++ b/spec/lib/gitlab/database/each_database_spec.rb @@ -58,6 +58,15 @@ RSpec.describe Gitlab::Database::EachDatabase do end end end + + context 'when shared connections are not included' do + it 'only yields the unshared connections' do + expect(Gitlab::Database).to receive(:db_config_share_with).twice.and_return(nil, 'main') + + expect { |b| described_class.each_database_connection(include_shared: false, &b) } + .to yield_successive_args([ActiveRecord::Base.connection, 'main']) + end + end end describe '.each_model_connection' do diff --git a/spec/lib/gitlab/database/load_balancing/setup_spec.rb b/spec/lib/gitlab/database/load_balancing/setup_spec.rb index 4d565ce137a..c44637b8d06 100644 --- a/spec/lib/gitlab/database/load_balancing/setup_spec.rb +++ b/spec/lib/gitlab/database/load_balancing/setup_spec.rb @@ -10,7 +10,6 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do expect(setup).to receive(:configure_connection) expect(setup).to receive(:setup_connection_proxy) expect(setup).to receive(:setup_service_discovery) - expect(setup).to receive(:setup_feature_flag_to_model_load_balancing) setup.setup end @@ -120,120 +119,46 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do end end - describe '#setup_feature_flag_to_model_load_balancing', :reestablished_active_record_base do + context 'uses correct base models', :reestablished_active_record_base do using RSpec::Parameterized::TableSyntax where do { - "with model LB enabled it picks a dedicated CI connection" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true', + "it picks a dedicated CI connection" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, request_store_active: false, - ff_use_model_load_balancing: nil, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'ci' } } }, - "with model LB enabled and re-use of primary connection it uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'true', + "with re-use of primary connection it uses CI connection for reads" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', request_store_active: false, - ff_use_model_load_balancing: nil, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'main' } } }, - "with model LB disabled it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false', - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: nil, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with model LB disabled, but re-use configured it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: 'false', + "with re-use and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads and writes" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', - request_store_active: false, - ff_use_model_load_balancing: nil, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing disabled without RequestStore it uses main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: false, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing enabled without RequestStore sticking of FF does not work, so it fallbacks to use main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: false, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing disabled with RequestStore it uses main" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, - request_store_active: true, - ff_use_model_load_balancing: false, - ff_force_no_sharing_primary_model: false, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'main_replica', write: 'main' } - } - }, - "with FF use_model_load_balancing enabled with RequestStore it sticks FF and uses CI connection" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: nil, request_store_active: true, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: false, + ff_force_no_sharing_primary_model: true, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'ci' } } }, - "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model disabled with RequestStore it sticks FF and uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, + "with re-use and FF force_no_sharing_primary_model enabled without RequestStore it doesn't use FF and uses CI connection for reads only" => { env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', request_store_active: true, - ff_use_model_load_balancing: true, ff_force_no_sharing_primary_model: false, expectations: { main: { read: 'main_replica', write: 'main' }, ci: { read: 'ci_replica', write: 'main' } } - }, - "with re-use and ff_use_model_load_balancing enabled and FF force_no_sharing_primary_model enabled with RequestStore it sticks FF and uses CI connection for reads" => { - env_GITLAB_USE_MODEL_LOAD_BALANCING: nil, - env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci: 'main', - request_store_active: true, - ff_use_model_load_balancing: true, - ff_force_no_sharing_primary_model: true, - expectations: { - main: { read: 'main_replica', write: 'main' }, - ci: { read: 'ci_replica', write: 'ci' } - } } } end @@ -285,9 +210,7 @@ RSpec.describe Gitlab::Database::LoadBalancing::Setup do end end - stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', env_GITLAB_USE_MODEL_LOAD_BALANCING) stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', env_GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci) - stub_feature_flags(use_model_load_balancing: ff_use_model_load_balancing) # Make load balancer to force init with a dedicated replicas connections models.each do |_, model| diff --git a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb index ad9a3a6e257..e7b5bad8626 100644 --- a/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/restrict_gitlab_schema_spec.rb @@ -240,7 +240,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def software_license_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'software_licenses' end end @@ -272,7 +272,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def ci_instance_variables_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'ci_instance_variables' end end @@ -303,7 +303,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a end def detached_partitions_class - Class.new(ActiveRecord::Base) do + Class.new(Gitlab::Database::Migration[2.0]::MigrationRecord) do self.table_name = 'detached_partitions' end end @@ -496,11 +496,16 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a Gitlab::Database.database_base_models.each do |db_config_name, model| context "for db_config_name=#{db_config_name}" do around do |example| + verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + with_reestablished_active_record_base do reconfigure_db_connection(model: ActiveRecord::Base, config_model: model) example.run end + ensure + ActiveRecord::Migration.verbose = verbose_was end before do @@ -543,8 +548,15 @@ RSpec.describe Gitlab::Database::MigrationHelpers::RestrictGitlabSchema, query_a expect { ignore_error(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas::DDLNotAllowedError) { migration_class.migrate(:down) } }.not_to raise_error when :skipped - expect { migration_class.migrate(:up) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError) - expect { migration_class.migrate(:down) }.to raise_error(Gitlab::Database::MigrationHelpers::RestrictGitlabSchema::MigrationSkippedError) + expect_next_instance_of(migration_class) do |migration_object| + expect(migration_object).to receive(:migration_skipped).and_call_original + expect(migration_object).not_to receive(:up) + expect(migration_object).not_to receive(:down) + expect(migration_object).not_to receive(:change) + end + + migration_class.migrate(:up) + migration_class.migrate(:down) end end end diff --git a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb index acf775b3538..5c054795697 100644 --- a/spec/lib/gitlab/database/migration_helpers/v2_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers/v2_spec.rb @@ -96,6 +96,12 @@ RSpec.describe Gitlab::Database::MigrationHelpers::V2 do expect(new_record_1.reload).to have_attributes(status: 1, original: 'updated', renamed: 'updated') expect(new_record_2.reload).to have_attributes(status: 1, original: nil, renamed: nil) end + + it 'requires the helper to run in ddl mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + + migration.public_send(operation, :_test_table, :original, :renamed) + end end describe '#rename_column_concurrently' do diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 9505da8fd12..798eee0de3e 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -1390,6 +1390,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'reverses the operations of cleanup_concurrent_column_type_change' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + expect(model).to receive(:check_trigger_permissions!).with(:users) expect(model).to receive(:create_column_from).with( @@ -1415,6 +1417,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'passes the type_cast_function, batch_column_name and limit' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + expect(model).to receive(:column_exists?).with(:users, :other_batch_column).and_return(true) expect(model).to receive(:check_trigger_permissions!).with(:users) @@ -2096,7 +2100,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end end - let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.active } + let(:migration_relation) { Gitlab::Database::BackgroundMigration::BatchedMigration.with_status(:active) } before do model.initialize_conversion_of_integer_to_bigint(table, columns) @@ -2218,7 +2222,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do subject(:ensure_batched_background_migration_is_finished) { model.ensure_batched_background_migration_is_finished(**configuration) } it 'raises an error when migration exists and is not marked as finished' do - create(:batched_background_migration, configuration.merge(status: :active)) + create(:batched_background_migration, :active, configuration) expect { ensure_batched_background_migration_is_finished } .to raise_error "Expected batched background migration for the given configuration to be marked as 'finished', but it is 'active':" \ @@ -2234,7 +2238,7 @@ RSpec.describe Gitlab::Database::MigrationHelpers do end it 'does not raise error when migration exists and is marked as finished' do - create(:batched_background_migration, configuration.merge(status: :finished)) + create(:batched_background_migration, :finished, configuration) expect { ensure_batched_background_migration_is_finished } .not_to raise_error @@ -2422,7 +2426,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers do def setup namespace = namespaces.create!(name: 'foo', path: 'foo', type: Namespaces::UserNamespace.sti_name) - projects.create!(namespace_id: namespace.id) + project_namespace = namespaces.create!(name: 'project-foo', path: 'project-foo', type: 'Project', parent_id: namespace.id, visibility_level: 20) + projects.create!(namespace_id: namespace.id, project_namespace_id: project_namespace.id) end it 'generates iids properly for models created after the migration' do diff --git a/spec/lib/gitlab/database/migration_spec.rb b/spec/lib/gitlab/database/migration_spec.rb index 287e738c24e..18bbc6c1dd3 100644 --- a/spec/lib/gitlab/database/migration_spec.rb +++ b/spec/lib/gitlab/database/migration_spec.rb @@ -32,7 +32,7 @@ RSpec.describe Gitlab::Database::Migration do # This breaks upon Rails upgrade. In that case, we'll add a new version in Gitlab::Database::Migration::MIGRATION_CLASSES, # bump .current_version and leave existing migrations and already defined versions of Gitlab::Database::Migration # untouched. - expect(described_class[described_class.current_version].superclass).to eq(ActiveRecord::Migration::Current) + expect(described_class[described_class.current_version]).to be < ActiveRecord::Migration::Current end end diff --git a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb index 37efff165c7..f9347a174c4 100644 --- a/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migrations/batched_background_migration_helpers_spec.rb @@ -75,7 +75,7 @@ RSpec.describe Gitlab::Database::Migrations::BatchedBackgroundMigrationHelpers d max_batch_size: 10000, sub_batch_size: 10, job_arguments: %w[], - status: 'active', + status_name: :active, total_tuple_count: pgclass_info.cardinality_estimate) end diff --git a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb index fd8303c379c..c31244060ec 100644 --- a/spec/lib/gitlab/database/migrations/instrumentation_spec.rb +++ b/spec/lib/gitlab/database/migrations/instrumentation_spec.rb @@ -11,6 +11,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do describe '#observe' do subject { described_class.new(result_dir: result_dir) } + def load_observation(result_dir, migration_name) + Gitlab::Json.parse(File.read(File.join(result_dir, migration_name, described_class::STATS_FILENAME))) + end + let(:migration_name) { 'test' } let(:migration_version) { '12345' } @@ -87,7 +91,7 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end context 'retrieving observations' do - subject { instance.observations.first } + subject { load_observation(result_dir, migration_name) } before do observe @@ -98,10 +102,10 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do end it 'records a valid observation', :aggregate_failures do - expect(subject.walltime).not_to be_nil - expect(subject.success).to be_falsey - expect(subject.version).to eq(migration_version) - expect(subject.name).to eq(migration_name) + expect(subject['walltime']).not_to be_nil + expect(subject['success']).to be_falsey + expect(subject['version']).to eq(migration_version) + expect(subject['name']).to eq(migration_name) end end end @@ -113,11 +117,18 @@ RSpec.describe Gitlab::Database::Migrations::Instrumentation do let(:migration1) { double('migration1', call: nil) } let(:migration2) { double('migration2', call: nil) } + let(:migration_name_2) { 'other_migration' } + let(:migration_version_2) { '98765' } + it 'records observations for all migrations' do subject.observe(version: migration_version, name: migration_name, connection: connection) {} - subject.observe(version: migration_version, name: migration_name, connection: connection) { raise 'something went wrong' } rescue nil + subject.observe(version: migration_version_2, name: migration_name_2, connection: connection) { raise 'something went wrong' } rescue nil + + expect { load_observation(result_dir, migration_name) }.not_to raise_error + expect { load_observation(result_dir, migration_name_2) }.not_to raise_error - expect(subject.observations.size).to eq(2) + # Each observation is a subdirectory of the result_dir, so here we check that we didn't record an extra one + expect(Pathname(result_dir).children.map { |d| d.basename.to_s }).to contain_exactly(migration_name, migration_name_2) end end end diff --git a/spec/lib/gitlab/database/migrations/runner_spec.rb b/spec/lib/gitlab/database/migrations/runner_spec.rb index 84482e6b450..8b1ccf05eb1 100644 --- a/spec/lib/gitlab/database/migrations/runner_spec.rb +++ b/spec/lib/gitlab/database/migrations/runner_spec.rb @@ -124,4 +124,16 @@ RSpec.describe Gitlab::Database::Migrations::Runner do expect(metadata).to match('version' => described_class::SCHEMA_VERSION) end end + + describe '.background_migrations' do + it 'is a TestBackgroundRunner' do + expect(described_class.background_migrations).to be_a(Gitlab::Database::Migrations::TestBackgroundRunner) + end + + it 'is configured with a result dir of /background_migrations' do + runner = described_class.background_migrations + + expect(runner.result_dir).to eq(described_class::BASE_RESULT_DIR.join( 'background_migrations')) + end + end end diff --git a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb index c6fe88a7c2d..9407efad91f 100644 --- a/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb +++ b/spec/lib/gitlab/database/migrations/test_background_runner_spec.rb @@ -11,11 +11,17 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do Sidekiq::Testing.disable! { ex.run } end + let(:result_dir) { Dir.mktmpdir } + + after do + FileUtils.rm_rf(result_dir) + end + context 'without jobs to run' do it 'returns immediately' do - runner = described_class.new + runner = described_class.new(result_dir: result_dir) expect(runner).not_to receive(:run_job) - described_class.new.run_jobs(for_duration: 1.second) + described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second) end end @@ -30,7 +36,7 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do context 'finding pending background jobs' do it 'finds all the migrations' do - expect(described_class.new.traditional_background_migrations.to_a.size).to eq(5) + expect(described_class.new(result_dir: result_dir).traditional_background_migrations.to_a.size).to eq(5) end end @@ -53,12 +59,28 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do end end + def expect_recorded_migration_runs(migrations_to_runs) + migrations_to_runs.each do |migration, runs| + path = File.join(result_dir, migration.name.demodulize) + num_subdirs = Pathname(path).children.count(&:directory?) + expect(num_subdirs).to eq(runs) + end + end + + def expect_migration_runs(migrations_to_run_counts) + expect_migration_call_counts(migrations_to_run_counts) + + yield + + expect_recorded_migration_runs(migrations_to_run_counts) + end + it 'runs the migration class correctly' do calls = [] define_background_migration(migration_name) do |i| calls << i end - described_class.new.run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time + described_class.new(result_dir: result_dir).run_jobs(for_duration: 1.second) # Any time would work here as we do not advance time expect(calls).to contain_exactly(1, 2, 3, 4, 5) end @@ -67,9 +89,9 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do travel(1.minute) end - expect_migration_call_counts(migration => 3) - - described_class.new.run_jobs(for_duration: 3.minutes) + expect_migration_runs(migration => 3) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + end end context 'with multiple migrations to run' do @@ -90,12 +112,12 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do travel(2.minutes) end - expect_migration_call_counts( + expect_migration_runs( migration => 2, # 1 minute jobs for 90 seconds, can finish the first and start the second other_migration => 1 # 2 minute jobs for 90 seconds, past deadline after a single job - ) - - described_class.new.run_jobs(for_duration: 3.minutes) + ) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + end end it 'does not give leftover time to extra migrations' do @@ -107,12 +129,13 @@ RSpec.describe Gitlab::Database::Migrations::TestBackgroundRunner, :redis do other_migration = define_background_migration(other_migration_name) do travel(1.minute) end - expect_migration_call_counts( + + expect_migration_runs( migration => 5, other_migration => 2 - ) - - described_class.new.run_jobs(for_duration: 3.minutes) + ) do + described_class.new(result_dir: result_dir).run_jobs(for_duration: 3.minutes) + 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 4f1d6302331..1026b4370a5 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 @@ -125,6 +125,17 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe expect_table_partitioned_by(partitioned_table, [partition_column]) end + it 'requires the migration helper to be run in DDL mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_ddl_mode!) + + migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date + + expect(connection.table_exists?(partitioned_table)).to be(true) + expect(connection.primary_key(partitioned_table)).to eq(new_primary_key) + + expect_table_partitioned_by(partitioned_table, [partition_column]) + end + it 'changes the primary key datatype to bigint' do migration.partition_table_by_date source_table, partition_column, min_date: min_date, max_date: max_date @@ -191,6 +202,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe end it 'creates a partition spanning over each month from the first record' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield + migration.partition_table_by_date source_table, partition_column, max_date: max_date expect_range_partitions_for(partitioned_table, { @@ -206,6 +219,8 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe context 'without data' do it 'creates the catchall partition plus two actual partition' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield + migration.partition_table_by_date source_table, partition_column, max_date: max_date expect_range_partitions_for(partitioned_table, { @@ -536,6 +551,16 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe migration.finalize_backfilling_partitioned_table source_table end + + it 'requires the migration helper to execute in DML mode' do + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:require_dml_mode!) + + expect(Gitlab::BackgroundMigration).to receive(:steal) + .with(described_class::MIGRATION_CLASS_NAME) + .and_yield(background_job) + + migration.finalize_backfilling_partitioned_table source_table + end end context 'when there is missed data' do @@ -627,6 +652,7 @@ RSpec.describe Gitlab::Database::PartitioningMigrationHelpers::TableManagementHe allow(backfill).to receive(:perform).and_return(1) end + expect(Gitlab::Database::QueryAnalyzers::RestrictAllowedSchemas).to receive(:with_suppressed).and_yield expect(migration).to receive(:disable_statement_timeout).and_call_original expect(migration).to receive(:execute).with("VACUUM FREEZE ANALYZE #{partitioned_table}") diff --git a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb index 86e74cf5177..b8c1ecd9089 100644 --- a/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb +++ b/spec/lib/gitlab/database/query_analyzers/gitlab_schemas_metrics_spec.rb @@ -17,7 +17,7 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana process_sql(ActiveRecord::Base, "SELECT 1 FROM projects") end - context 'properly observes all queries', :add_ci_connection do + context 'properly observes all queries', :add_ci_connection, :request_store do using RSpec::Parameterized::TableSyntax where do @@ -28,7 +28,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing gitlab_ci and gitlab_main" => { model: ApplicationRecord, @@ -36,7 +37,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_ci,gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing gitlab_ci and gitlab_main the gitlab_schemas is always ordered" => { model: ApplicationRecord, @@ -44,7 +46,8 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana expectations: { gitlab_schemas: "gitlab_ci,gitlab_main", db_config_name: "main" - } + }, + setup: nil }, "for query accessing CI database" => { model: Ci::ApplicationRecord, @@ -53,6 +56,62 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana gitlab_schemas: "gitlab_ci", db_config_name: "ci" } + }, + "for query accessing CI database with re-use and disabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + stub_feature_flags(force_no_sharing_primary_model: true) + end + }, + "for query accessing CI database with re-use and enabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: false + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', 'main') + stub_feature_flags(force_no_sharing_primary_model: false) + end + }, + "for query accessing CI database without re-use and disabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + stub_feature_flags(force_no_sharing_primary_model: true) + end + }, + "for query accessing CI database without re-use and enabled sharing" => { + model: Ci::ApplicationRecord, + sql: "SELECT 1 FROM ci_builds", + expectations: { + gitlab_schemas: "gitlab_ci", + db_config_name: "ci", + ci_dedicated_primary_connection: true + }, + setup: ->(_) do + skip_if_multiple_databases_not_setup + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + stub_feature_flags(force_no_sharing_primary_model: false) + end } } end @@ -63,8 +122,15 @@ RSpec.describe Gitlab::Database::QueryAnalyzers::GitlabSchemasMetrics, query_ana end it do + stub_env('GITLAB_LOAD_BALANCING_REUSE_PRIMARY_ci', nil) + + instance_eval(&setup) if setup + + allow(::Ci::ApplicationRecord.load_balancer).to receive(:configuration) + .and_return(Gitlab::Database::LoadBalancing::Configuration.for_model(::Ci::ApplicationRecord)) + expect(described_class.schemas_metrics).to receive(:increment) - .with(expectations).and_call_original + .with({ ci_dedicated_primary_connection: anything }.merge(expectations)).and_call_original process_sql(model, sql) end diff --git a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb index e76718fe48a..34670696787 100644 --- a/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb +++ b/spec/lib/gitlab/database/reindexing/grafana_notifier_spec.rb @@ -74,8 +74,28 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do end describe '#notify_start' do - context 'additional tag is nil' do - subject { described_class.new(api_key, api_url, nil).notify_start(action) } + context 'when Grafana is configured using application settings' do + subject { described_class.new.notify_start(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Started reindexing of #{action.index.name} on #{action.index.tablename}" + } + end + + before do + stub_application_setting(database_grafana_api_key: api_key) + stub_application_setting(database_grafana_api_url: api_url) + stub_application_setting(database_grafana_tag: additional_tag) + end + + it_behaves_like 'interacting with Grafana annotations API' + end + + context 'when there is no additional tag' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_start(action) } let(:payload) do { @@ -88,8 +108,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do it_behaves_like 'interacting with Grafana annotations API' end - context 'additional tag is not nil' do - subject { described_class.new(api_key, api_url, additional_tag).notify_start(action) } + context 'additional tag is provided' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_start(action) } let(:payload) do { @@ -104,8 +124,30 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do end describe '#notify_end' do - context 'additional tag is nil' do - subject { described_class.new(api_key, api_url, nil).notify_end(action) } + context 'when Grafana is configured using application settings' do + subject { described_class.new.notify_end(action) } + + let(:payload) do + { + time: (action.action_start.utc.to_f * 1000).to_i, + tags: ['reindex', additional_tag, action.index.tablename, action.index.name], + text: "Finished reindexing of #{action.index.name} on #{action.index.tablename} (#{action.state})", + timeEnd: (action.action_end.utc.to_f * 1000).to_i, + isRegion: true + } + end + + before do + stub_application_setting(database_grafana_api_key: api_key) + stub_application_setting(database_grafana_api_url: api_url) + stub_application_setting(database_grafana_tag: additional_tag) + end + + it_behaves_like 'interacting with Grafana annotations API' + end + + context 'when there is no additional tag' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: '').notify_end(action) } let(:payload) do { @@ -120,8 +162,8 @@ RSpec.describe Gitlab::Database::Reindexing::GrafanaNotifier do it_behaves_like 'interacting with Grafana annotations API' end - context 'additional tag is not nil' do - subject { described_class.new(api_key, api_url, additional_tag).notify_end(action) } + context 'additional tag is provided' do + subject { described_class.new(api_key: api_key, api_url: api_url, additional_tag: additional_tag).notify_end(action) } let(:payload) do { diff --git a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb index 7caee414719..0bea348e6b4 100644 --- a/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb +++ b/spec/lib/gitlab/database/schema_cache_with_renamed_table_spec.rb @@ -68,8 +68,8 @@ RSpec.describe Gitlab::Database::SchemaCacheWithRenamedTable do describe 'when the table behind a model is actually a view' do let(:group) { create(:group) } - let(:project_attributes) { attributes_for(:project, namespace_id: group.id).except(:creator) } - let(:record) { old_model.create!(project_attributes) } + let(:attrs) { attributes_for(:project, namespace_id: group.id, project_namespace_id: group.id).except(:creator) } + let(:record) { old_model.create!(attrs) } it 'can persist records' do expect(record.reload.attributes).to eq(new_model.find(record.id).attributes) diff --git a/spec/lib/gitlab/database_spec.rb b/spec/lib/gitlab/database_spec.rb index c58dba213ee..ac8616f84a7 100644 --- a/spec/lib/gitlab/database_spec.rb +++ b/spec/lib/gitlab/database_spec.rb @@ -185,16 +185,6 @@ RSpec.describe Gitlab::Database do end end - describe '.nulls_last_order' do - it { expect(described_class.nulls_last_order('column', 'ASC')).to eq 'column ASC NULLS LAST'} - it { expect(described_class.nulls_last_order('column', 'DESC')).to eq 'column DESC NULLS LAST'} - end - - describe '.nulls_first_order' do - it { expect(described_class.nulls_first_order('column', 'ASC')).to eq 'column ASC NULLS FIRST'} - it { expect(described_class.nulls_first_order('column', 'DESC')).to eq 'column DESC NULLS FIRST'} - end - describe '.db_config_for_connection' do context 'when the regular connection is used' do it 'returns db_config' do @@ -245,15 +235,32 @@ RSpec.describe Gitlab::Database do end end + describe '.db_config_names' do + let(:expected) { %w[foo bar] } + + it 'includes only main by default' do + allow(::ActiveRecord::Base).to receive(:configurations).and_return( + double(configs_for: %w[foo bar].map { |x| double(name: x) }) + ) + + expect(described_class.db_config_names).to eq(expected) + end + + it 'excludes geo when that is included' do + allow(::ActiveRecord::Base).to receive(:configurations).and_return( + double(configs_for: %w[foo bar geo].map { |x| double(name: x) }) + ) + + expect(described_class.db_config_names).to eq(expected) + end + end + describe '.gitlab_schemas_for_connection' do it 'does raise exception for invalid connection' do expect { described_class.gitlab_schemas_for_connection(:invalid) }.to raise_error /key not found: "unknown"/ end it 'does return a valid schema depending on a base model used', :request_store do - # This is currently required as otherwise the `Ci::Build.connection` == `Project.connection` - # ENV due to lib/gitlab/database/load_balancing/setup.rb:93 - stub_env('GITLAB_USE_MODEL_LOAD_BALANCING', '1') # FF due to lib/gitlab/database/load_balancing/configuration.rb:92 stub_feature_flags(force_no_sharing_primary_model: true) @@ -268,6 +275,47 @@ RSpec.describe Gitlab::Database do expect(described_class.gitlab_schemas_for_connection(ActiveRecord::Base.connection)).to include(:gitlab_ci, :gitlab_shared) end end + + context "when there's CI connection", :request_store do + before do + skip_if_multiple_databases_not_setup + + # FF due to lib/gitlab/database/load_balancing/configuration.rb:92 + # Requires usage of `:request_store` + stub_feature_flags(force_no_sharing_primary_model: true) + end + + context 'when CI uses database_tasks: false does indicate that ci: is subset of main:' do + before do + allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(false) + end + + it 'does return gitlab_ci when accessing via main: connection' do + expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_ci, :gitlab_main, :gitlab_shared) + end + + it 'does not return gitlab_main when accessing via ci: connection' do + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main) + end + end + + context 'when CI uses database_tasks: true does indicate that ci: has own database' do + before do + allow(Ci::ApplicationRecord.connection_db_config).to receive(:database_tasks?).and_return(true) + end + + it 'does not return gitlab_ci when accessing via main: connection' do + expect(described_class.gitlab_schemas_for_connection(Project.connection)).to include(:gitlab_main, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Project.connection)).not_to include(:gitlab_ci) + end + + it 'does not return gitlab_main when accessing via ci: connection' do + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).to include(:gitlab_ci, :gitlab_shared) + expect(described_class.gitlab_schemas_for_connection(Ci::Build.connection)).not_to include(:gitlab_main) + end + end + end end describe '#true_value' do diff --git a/spec/lib/gitlab/diff/custom_diff_spec.rb b/spec/lib/gitlab/diff/custom_diff_spec.rb index 246508d2e1e..77d2a6cbcd6 100644 --- a/spec/lib/gitlab/diff/custom_diff_spec.rb +++ b/spec/lib/gitlab/diff/custom_diff_spec.rb @@ -34,6 +34,59 @@ RSpec.describe Gitlab::Diff::CustomDiff do expect(described_class.transformed_for_diff?(blob)).to be_falsey end end + + context 'timeout' do + subject { described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob) } + + it 'falls back to nil on timeout' do + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + + expect(subject).to be_nil + end + + context 'when in foreground' do + it 'utilizes timeout for web' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original + + expect(subject).not_to include('cells') + end + + it 'increments metrics' do + counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc') + + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + expect { subject }.to change { counter.get(source: described_class::FOREGROUND_EXECUTION) }.by(1) + end + end + + context 'when in background' do + before do + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) + end + + it 'utilizes longer timeout for sidekiq' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original + + expect(subject).not_to include('cells') + end + + it 'increments metrics' do + counter = Gitlab::Metrics.counter(:ipynb_semantic_diff_timeouts_total, 'desc') + + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + expect { subject }.to change { counter.get(source: described_class::BACKGROUND_EXECUTION) }.by(1) + end + end + end + + context 'when invalid ipynb' do + it 'returns nil' do + expect(ipynb_blob).to receive(:data).and_return('invalid ipynb') + + expect(described_class.preprocess_before_diff(ipynb_blob.path, nil, ipynb_blob)).to be_nil + end + end end describe '#transformed_blob_data' do diff --git a/spec/lib/gitlab/diff/file_spec.rb b/spec/lib/gitlab/diff/file_spec.rb index f2212ec9b09..0d7a183bb11 100644 --- a/spec/lib/gitlab/diff/file_spec.rb +++ b/spec/lib/gitlab/diff/file_spec.rb @@ -51,6 +51,54 @@ RSpec.describe Gitlab::Diff::File do project.commit(branch_name).diffs.diff_files.first end + describe '#initialize' do + let(:commit) { project.commit("532c837") } + + context 'when file is ipynb' do + let(:ipynb_semantic_diff) { false } + let(:rendered_diffs_viewer) { false } + + before do + stub_feature_flags(ipynb_semantic_diff: ipynb_semantic_diff, rendered_diffs_viewer: rendered_diffs_viewer) + end + + context 'when ipynb_semantic_diff is off, and rendered_viewer is off' do + it 'does not generate notebook diffs' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is off, and rendered_viewer is on' do + let(:rendered_diffs_viewer) { true } + + it 'does not generate rendered diff' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is on, and rendered_viewer is off' do + let(:ipynb_semantic_diff) { true } + + it 'transforms using custom diff CustomDiff' do + expect(Gitlab::Diff::CustomDiff).to receive(:preprocess_before_diff).and_call_original + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb_semantic_diff is on, and rendered_viewer is on' do + let(:ipynb_semantic_diff) { true } + let(:rendered_diffs_viewer) { true } + + it 'transforms diff using NotebookDiffFile' do + expect(Gitlab::Diff::CustomDiff).not_to receive(:preprocess_before_diff) + expect(diff_file.rendered).not_to be_nil + end + end + end + end + describe '#has_renderable?' do context 'file is ipynb' do let(:commit) { project.commit("532c837") } @@ -66,14 +114,58 @@ RSpec.describe Gitlab::Diff::File do it 'does not have renderable viewer' do expect(diff_file.has_renderable?).to be_falsey end + + it 'does not create a Notebook DiffFile' do + expect(diff_file.rendered).to be_nil + + expect(::Gitlab::Diff::Rendered::Notebook::DiffFile).not_to receive(:new) + end end end describe '#rendered' do - let(:commit) { project.commit("532c837") } + context 'when not ipynb' do + it 'is nil' do + expect(diff_file.rendered).to be_nil + end + end + + context 'when ipynb' do + let(:commit) { project.commit("532c837") } + + it 'creates a NotebookDiffFile for rendering' do + expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile) + end + + context 'when too large' do + it 'is nil' do + expect(diff).to receive(:too_large?).and_return(true) + + expect(diff_file.rendered).to be_nil + end + end + + context 'when not modified' do + it 'is nil' do + expect(diff_file).to receive(:modified_file?).and_return(false) + + expect(diff_file.rendered).to be_nil + end + end + + context 'when semantic ipynb is off' do + before do + stub_feature_flags(ipynb_semantic_diff: false) + end + + it 'returns nil' do + expect(diff_file).not_to receive(:modified_file?) + expect(diff_file).not_to receive(:ipynb?) + expect(diff).not_to receive(:too_large?) - it 'creates a NotebookDiffFile for rendering' do - expect(diff_file.rendered).to be_kind_of(Gitlab::Diff::Rendered::Notebook::DiffFile) + expect(diff_file.rendered).to be_nil + end + end end end diff --git a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb index 15edbc22460..89b284feee0 100644 --- a/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb +++ b/spec/lib/gitlab/diff/rendered/notebook/diff_file_spec.rb @@ -63,6 +63,28 @@ RSpec.describe Gitlab::Diff::Rendered::Notebook::DiffFile do expect(nb_file.diff).to be_nil end end + + context 'timeout' do + it 'utilizes timeout for web' do + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_FOREGROUND).and_call_original + + nb_file.diff + end + + it 'falls back to nil on timeout' do + allow(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception) + expect(Timeout).to receive(:timeout).and_raise(Timeout::Error) + + expect(nb_file.diff).to be_nil + end + + it 'utilizes longer timeout for sidekiq' do + allow(Gitlab::Runtime).to receive(:sidekiq?).and_return(true) + expect(Timeout).to receive(:timeout).with(described_class::RENDERED_TIMEOUT_BACKGROUND).and_call_original + + nb_file.diff + end + end end describe '#has_renderable?' do diff --git a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb index 913e197708f..8d008986464 100644 --- a/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/service_desk_handler_spec.rb @@ -477,20 +477,6 @@ RSpec.describe Gitlab::Email::Handler::ServiceDeskHandler do end end - context 'when there is a reply-to address and a from address' do - let(:email_raw) { email_fixture('emails/service_desk_reply_to_and_from.eml') } - - it 'shows both from and reply-to addresses in the issue header' do - setup_attachment - - expect { receiver.execute }.to change { Issue.count }.by(1) - - new_issue = Issue.last - - expect(new_issue.external_author).to eq('finn@adventuretime.ooo (reply to: marceline@adventuretime.ooo)') - end - end - context 'when service desk is not enabled for project' do before do allow(Gitlab::ServiceDesk).to receive(:enabled?).and_return(false) diff --git a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb index 0521123f1ef..8bd873cf008 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing/base_spec.rb @@ -100,7 +100,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing::Base do :trial | true :team | true :experience | true - :invite_team | false end with_them do diff --git a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb deleted file mode 100644 index 8319560f594..00000000000 --- a/spec/lib/gitlab/email/message/in_product_marketing/invite_team_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Email::Message::InProductMarketing::InviteTeam do - let_it_be(:group) { build(:group) } - let_it_be(:user) { build(:user) } - - let(:series) { 0 } - - subject(:message) { described_class.new(group: group, user: user, series: series) } - - describe 'initialize' do - context 'when series is valid' do - it 'does not raise error' do - expect { subject }.not_to raise_error(ArgumentError) - end - end - - context 'when series is invalid' do - let(:series) { 1 } - - it 'raises error' do - expect { subject }.to raise_error(ArgumentError) - end - end - end - - it 'contains the correct message', :aggregate_failures do - expect(message.subject_line).to eq 'Invite your teammates to GitLab' - expect(message.tagline).to be_empty - expect(message.title).to eq 'GitLab is better with teammates to help out!' - expect(message.subtitle).to be_empty - expect(message.body_line1).to eq 'Invite your teammates today and build better code together. You can even assign tasks to new teammates such as setting up CI/CD, to help get projects up and running.' - expect(message.body_line2).to be_empty - expect(message.cta_text).to eq 'Invite your teammates to help' - expect(message.logo_path).to eq 'mailers/in_product_marketing/team-0.png' - end -end diff --git a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb index 594df7440bb..40351bef8b9 100644 --- a/spec/lib/gitlab/email/message/in_product_marketing_spec.rb +++ b/spec/lib/gitlab/email/message/in_product_marketing_spec.rb @@ -18,7 +18,6 @@ RSpec.describe Gitlab::Email::Message::InProductMarketing do :trial | described_class::Trial :team | described_class::Team :experience | described_class::Experience - :invite_team | described_class::InviteTeam end with_them do diff --git a/spec/lib/gitlab/encoding_helper_spec.rb b/spec/lib/gitlab/encoding_helper_spec.rb index 98170ef437c..4c1fbb93c13 100644 --- a/spec/lib/gitlab/encoding_helper_spec.rb +++ b/spec/lib/gitlab/encoding_helper_spec.rb @@ -265,4 +265,14 @@ RSpec.describe Gitlab::EncodingHelper do end end end + + describe '#unquote_path' do + it do + expect(described_class.unquote_path('unquoted')).to eq('unquoted') + expect(described_class.unquote_path('"quoted"')).to eq('quoted') + expect(described_class.unquote_path('"\\311\\240\\304\\253\\305\\247\\305\\200\\310\\247\\306\\200"')).to eq('ɠīŧŀȧƀ') + expect(described_class.unquote_path('"\\\\303\\\\251"')).to eq('\303\251') + expect(described_class.unquote_path('"\a\b\e\f\n\r\t\v\""')).to eq("\a\b\e\f\n\r\t\v\"") + end + end end diff --git a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb index 5b78acc3b1d..f878f02f410 100644 --- a/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/uploads_rewriter_spec.rb @@ -67,7 +67,7 @@ RSpec.describe Gitlab::Gfm::UploadsRewriter do it 'does not rewrite plain links as embedded' do embedded_link = image_uploader.markdown_link - plain_image_link = embedded_link.sub(/\A!/, "") + plain_image_link = embedded_link.delete_prefix('!') text = "#{plain_image_link} and #{embedded_link}" moved_text = described_class.new(text, old_project, user).rewrite(new_project) diff --git a/spec/lib/gitlab/git/blame_spec.rb b/spec/lib/gitlab/git/blame_spec.rb index 495cb16ebab..7dd7460b142 100644 --- a/spec/lib/gitlab/git/blame_spec.rb +++ b/spec/lib/gitlab/git/blame_spec.rb @@ -4,71 +4,81 @@ require "spec_helper" RSpec.describe Gitlab::Git::Blame, :seed_helper do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '', 'group/project') } - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::Commit::ID, "CONTRIBUTING.md") + + let(:sha) { SeedRepo::Commit::ID } + let(:path) { 'CONTRIBUTING.md' } + let(:range) { nil } + + subject(:blame) { Gitlab::Git::Blame.new(repository, sha, path, range: range) } + + let(:result) do + [].tap do |data| + blame.each do |commit, line, previous_path| + data << { commit: commit, line: line, previous_path: previous_path } + end + end end describe 'blaming a file' do - context "each count" do - it do - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end + it 'has the right number of lines' do + expect(result.size).to eq(95) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq("# Contribute to GitLab") + expect(result.first[:line]).to be_utf8 + end - expect(data.size).to eq(95) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq("# Contribute to GitLab") - expect(data.first[:line]).to be_utf8 + context 'blaming a range' do + let(:range) { 2..4 } + + it 'only returns the range' do + expect(result.size).to eq(range.size) + expect(result.map {|r| r[:line] }).to eq(['', 'This guide details how contribute to GitLab.', '']) end end context "ISO-8859 encoding" do - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt") - end + let(:sha) { SeedRepo::EncodingCommit::ID } + let(:path) { 'encoding/iso8859.txt' } it 'converts to UTF-8' do - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end - - expect(data.size).to eq(1) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq("Ä ü") - expect(data.first[:line]).to be_utf8 + expect(result.size).to eq(1) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq("Ä ü") + expect(result.first[:line]).to be_utf8 end end context "unknown encoding" do - let(:blame) do - Gitlab::Git::Blame.new(repository, SeedRepo::EncodingCommit::ID, "encoding/iso8859.txt") - end + let(:sha) { SeedRepo::EncodingCommit::ID } + let(:path) { 'encoding/iso8859.txt' } it 'converts to UTF-8' do expect_next_instance_of(CharlockHolmes::EncodingDetector) do |detector| expect(detector).to receive(:detect).and_return(nil) end - data = [] - blame.each do |commit, line| - data << { - commit: commit, - line: line - } - end + expect(result.size).to eq(1) + expect(result.first[:commit]).to be_kind_of(Gitlab::Git::Commit) + expect(result.first[:line]).to eq(" ") + expect(result.first[:line]).to be_utf8 + end + end + + context "renamed file" do + let(:project) { create(:project, :repository) } + let(:repository) { project.repository.raw_repository } + let(:commit) { project.commit('blame-on-renamed') } + let(:sha) { commit.id } + let(:path) { 'files/plain_text/renamed' } + + it 'includes the previous path' do + expect(result.size).to eq(5) - expect(data.size).to eq(1) - expect(data.first[:commit]).to be_kind_of(Gitlab::Git::Commit) - expect(data.first[:line]).to eq(" ") - expect(data.first[:line]).to be_utf8 + expect(result[0]).to include(line: 'Initial commit', previous_path: nil) + expect(result[1]).to include(line: 'Initial commit', previous_path: nil) + expect(result[2]).to include(line: 'Renamed as "filename"', previous_path: 'files/plain_text/initial-commit') + expect(result[3]).to include(line: 'Renamed as renamed', previous_path: 'files/plain_text/"filename"') + expect(result[4]).to include(line: 'Last edit, no rename', previous_path: path) end end end diff --git a/spec/lib/gitlab/git/diff_spec.rb b/spec/lib/gitlab/git/diff_spec.rb index 17bb83d0f2f..46f544797bb 100644 --- a/spec/lib/gitlab/git/diff_spec.rb +++ b/spec/lib/gitlab/git/diff_spec.rb @@ -161,6 +161,52 @@ EOT expect(diff).not_to have_binary_notice end end + + context 'when diff contains invalid characters' do + let(:bad_string) { [0xae].pack("C*") } + let(:bad_string_two) { [0x89].pack("C*") } + + let(:diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string })) } + let(:diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two })) } + + context 'when replace_invalid_utf8_chars is true' do + it 'will convert invalid characters and not cause an encoding error' do + expect(diff.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER) + expect(diff_two.diff).to include(Gitlab::EncodingHelper::UNICODE_REPLACEMENT_CHARACTER) + + expect { Oj.dump(diff) }.not_to raise_error(EncodingError) + expect { Oj.dump(diff_two) }.not_to raise_error(EncodingError) + end + + context 'when the diff is binary' do + let(:project) { create(:project, :repository) } + + it 'will not try to replace characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + expect(binary_diff(project).diff).not_to be_empty + end + end + + context 'when convert_diff_to_utf8_with_replacement_symbol feature flag is disabled' do + before do + stub_feature_flags(convert_diff_to_utf8_with_replacement_symbol: false) + end + + it 'will not try to convert invalid characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + end + end + end + + context 'when replace_invalid_utf8_chars is false' do + let(:not_replaced_diff) { described_class.new(@raw_diff_hash.merge({ diff: bad_string, replace_invalid_utf8_chars: false }) ) } + let(:not_replaced_diff_two) { described_class.new(@raw_diff_hash.merge({ diff: bad_string_two, replace_invalid_utf8_chars: false }) ) } + + it 'will not try to convert invalid characters' do + expect(Gitlab::EncodingHelper).not_to receive(:encode_utf8_with_replacement_character?) + end + end + end end describe 'straight diffs' do @@ -255,12 +301,11 @@ EOT let(:project) { create(:project, :repository) } it 'fake binary message when it detects binary' do - # Rugged will not detect this as binary, but we can fake it diff_message = "Binary files files/images/icn-time-tracking.pdf and files/images/icn-time-tracking.pdf differ\n" - binary_diff = described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first - expect(binary_diff.diff).not_to be_empty - expect(binary_diff.json_safe_diff).to eq(diff_message) + diff = binary_diff(project) + expect(diff.diff).not_to be_empty + expect(diff.json_safe_diff).to eq(diff_message) end it 'leave non-binary diffs as-is' do @@ -374,4 +419,9 @@ EOT expect(diff.line_count).to eq(0) end end + + def binary_diff(project) + # rugged will not detect this as binary, but we can fake it + described_class.between(project.repository, 'add-pdf-text-binary', 'add-pdf-text-binary^').first + end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index ae6ca728573..47688c4b3e6 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -2448,7 +2448,7 @@ RSpec.describe Gitlab::Git::Repository, :seed_helper do it 'delegates to Gitaly' do expect_next_instance_of(Gitlab::GitalyClient::RepositoryService) do |svc| - expect(svc).to receive(:import_repository).with(url).and_return(nil) + expect(svc).to receive(:import_repository).with(url, http_authorization_header: '', mirror: false).and_return(nil) end repository.import_repository(url) diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 8d9ab5db886..50a0f20e775 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -563,4 +563,39 @@ RSpec.describe Gitlab::GitalyClient::CommitService do expect(response).not_to have_key 'nonexistent' end end + + describe '#raw_blame' do + let(:project) { create(:project, :test_repo) } + let(:revision) { 'blame-on-renamed' } + let(:path) { 'files/plain_text/renamed' } + + let(:blame_headers) do + [ + '405a45736a75e439bb059e638afaa9a3c2eeda79 1 1 2', + '405a45736a75e439bb059e638afaa9a3c2eeda79 2 2', + 'bed1d1610ebab382830ee888288bf939c43873bb 3 3 1', + '3685515c40444faf92774e72835e1f9c0e809672 4 4 1', + '32c33da59f8a1a9f90bdeda570337888b00b244d 5 5 1' + ] + end + + subject(:blame) { client.raw_blame(revision, path, range: range).split("\n") } + + context 'without a range' do + let(:range) { nil } + + it 'blames a whole file' do + is_expected.to include(*blame_headers) + end + end + + context 'with a range' do + let(:range) { '3,4' } + + it 'blames part of a file' do + is_expected.to include(blame_headers[2], blame_headers[3]) + is_expected.not_to include(blame_headers[0], blame_headers[1], blame_headers[4]) + end + end + end end diff --git a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb index 1c7b35ed928..6eb92cdeab9 100644 --- a/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/diff_notes_importer_spec.rb @@ -98,9 +98,9 @@ RSpec.describe Gitlab::GithubImport::Importer::DiffNotesImporter do .to receive(:each_object_to_import) .and_yield(github_comment) - expect(Gitlab::GithubImport::ImportDiffNoteWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportDiffNoteWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb index 2c2b6a2aff0..6b807bdf098 100644 --- a/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/issues_importer_spec.rb @@ -91,9 +91,9 @@ RSpec.describe Gitlab::GithubImport::Importer::IssuesImporter do .to receive(:each_object_to_import) .and_yield(github_issue) - expect(Gitlab::GithubImport::ImportIssueWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportIssueWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import 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 a2c7d51214a..6dfd4424342 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 @@ -118,9 +118,9 @@ RSpec.describe Gitlab::GithubImport::Importer::LfsObjectsImporter do expect(service).to receive(:execute).and_return([lfs_download_object]) end - expect(Gitlab::GithubImport::ImportLfsObjectWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportLfsObjectWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb index 3782dab5ee3..3b4fe652da8 100644 --- a/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb +++ b/spec/lib/gitlab/github_import/importer/notes_importer_spec.rb @@ -84,9 +84,9 @@ RSpec.describe Gitlab::GithubImport::Importer::NotesImporter do .to receive(:each_object_to_import) .and_yield(github_comment) - expect(Gitlab::GithubImport::ImportNoteWorker) - .to receive(:perform_async) - .with(project.id, an_instance_of(Hash), an_instance_of(String)) + expect(Gitlab::GithubImport::ImportNoteWorker).to receive(:bulk_perform_in).with(1.second, [ + [project.id, an_instance_of(Hash), an_instance_of(String)] + ], batch_size: 1000, batch_delay: 1.minute) waiter = importer.parallel_import diff --git a/spec/lib/gitlab/github_import/object_counter_spec.rb b/spec/lib/gitlab/github_import/object_counter_spec.rb index c9e4ac67061..e522f74416c 100644 --- a/spec/lib/gitlab/github_import/object_counter_spec.rb +++ b/spec/lib/gitlab/github_import/object_counter_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do - let_it_be(:project) { create(:project) } + let_it_be(:project) { create(:project, :import_started, import_type: 'github') } it 'validates the operation being incremented' do expect { described_class.increment(project, :issue, :unknown) } @@ -49,4 +49,12 @@ RSpec.describe Gitlab::GithubImport::ObjectCounter, :clean_gitlab_redis_cache do 'imported' => {} }) end + + it 'expires etag cache of relevant realtime change endpoints on increment' do + expect_next_instance_of(Gitlab::EtagCaching::Store) do |instance| + expect(instance).to receive(:touch).with(Gitlab::Routing.url_helpers.realtime_changes_import_github_path(format: :json)) + end + + described_class.increment(project, :issue, :fetched) + end end diff --git a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb index 6a19afbc60d..200898f8f03 100644 --- a/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb +++ b/spec/lib/gitlab/github_import/parallel_scheduling_spec.rb @@ -22,10 +22,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do def collection_method :issues end - - def parallel_import_batch - { size: 10, delay: 1.minute } - end end end @@ -261,7 +257,7 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do let(:repr_class) { double(:representation) } let(:worker_class) { double(:worker) } let(:object) { double(:object) } - let(:batch_size) { 200 } + let(:batch_size) { 1000 } let(:batch_delay) { 1.minute } before do @@ -281,7 +277,6 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do context 'with multiple objects' do before do - allow(importer).to receive(:parallel_import_batch) { { size: batch_size, delay: batch_delay } } expect(importer).to receive(:each_object_to_import).and_yield(object).and_yield(object).and_yield(object) end @@ -296,9 +291,9 @@ RSpec.describe Gitlab::GithubImport::ParallelScheduling do end end - context 'when FF is disabled' do + context 'when distribute_github_parallel_import feature flag is disabled' do before do - stub_feature_flags(spread_parallel_import: false) + stub_feature_flags(distribute_github_parallel_import: false) end it 'imports data in parallel' do diff --git a/spec/lib/gitlab/gon_helper_spec.rb b/spec/lib/gitlab/gon_helper_spec.rb index 047873d8237..28cb9125af1 100644 --- a/spec/lib/gitlab/gon_helper_spec.rb +++ b/spec/lib/gitlab/gon_helper_spec.rb @@ -64,6 +64,34 @@ RSpec.describe Gitlab::GonHelper do end end + describe '#push_force_frontend_feature_flag' do + let(:gon) { class_double('Gon') } + + before do + skip_feature_flags_yaml_validation + + allow(helper) + .to receive(:gon) + .and_return(gon) + end + + it 'pushes a feature flag to the frontend with the provided value' do + expect(gon) + .to receive(:push) + .with({ features: { 'myFeatureFlag' => true } }, true) + + helper.push_force_frontend_feature_flag(:my_feature_flag, true) + end + + it 'pushes a disabled feature flag if provided value is nil' do + expect(gon) + .to receive(:push) + .with({ features: { 'myFeatureFlag' => false } }, true) + + helper.push_force_frontend_feature_flag(:my_feature_flag, nil) + end + end + describe '#default_avatar_url' do it 'returns an absolute URL' do url = helper.default_avatar_url diff --git a/spec/lib/gitlab/graphql/known_operations_spec.rb b/spec/lib/gitlab/graphql/known_operations_spec.rb index 411c0876f82..3ebfefbb43c 100644 --- a/spec/lib/gitlab/graphql/known_operations_spec.rb +++ b/spec/lib/gitlab/graphql/known_operations_spec.rb @@ -19,7 +19,7 @@ RSpec.describe Gitlab::Graphql::KnownOperations do describe "#from_query" do where(:query_string, :expected) do - "query { helloWorld }" | described_class::ANONYMOUS + "query { helloWorld }" | described_class::UNKNOWN "query fuzzyyy { helloWorld }" | described_class::UNKNOWN "query foo { helloWorld }" | described_class::Operation.new("foo") end @@ -35,13 +35,13 @@ RSpec.describe Gitlab::Graphql::KnownOperations do describe "#operations" do it "returns array of known operations" do - expect(subject.operations.map(&:name)).to match_array(%w(anonymous unknown foo bar)) + expect(subject.operations.map(&:name)).to match_array(%w(unknown foo bar)) end end describe "Operation#to_caller_id" do where(:query_string, :expected) do - "query { helloWorld }" | "graphql:#{described_class::ANONYMOUS.name}" + "query { helloWorld }" | "graphql:#{described_class::UNKNOWN.name}" "query foo { helloWorld }" | "graphql:foo" end diff --git a/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb new file mode 100644 index 00000000000..320c6b52308 --- /dev/null +++ b/spec/lib/gitlab/graphql/pagination/active_record_array_connection_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Graphql::Pagination::ActiveRecordArrayConnection do + using RSpec::Parameterized::TableSyntax + + let_it_be(:items) { create_list(:package_build_info, 3) } + + let_it_be(:context) do + GraphQL::Query::Context.new( + query: GraphQL::Query.new(GitlabSchema, document: nil, context: {}, variables: {}), + values: {}, + object: nil + ) + end + + let(:first) { nil } + let(:last) { nil } + let(:after) { nil } + let(:before) { nil } + let(:max_page_size) { nil } + + let(:connection) do + described_class.new( + items, + context: context, + first: first, + last: last, + after: after, + before: before, + max_page_size: max_page_size + ) + end + + it_behaves_like 'a connection with collection methods' + + it_behaves_like 'a redactable connection' do + let(:unwanted) { items[1] } + end + + describe '#nodes' do + subject { connection.nodes } + + it { is_expected.to match_array(items) } + + context 'with first set' do + let(:first) { 2 } + + it { is_expected.to match_array([items[0], items[1]]) } + end + + context 'with last set' do + let(:last) { 2 } + + it { is_expected.to match_array([items[1], items[2]]) } + end + end + + describe '#next_page?' do + subject { connection.next_page? } + + where(:before, :first, :max_page_size, :result) do + nil | nil | nil | false + 1 | nil | nil | true + nil | 1 | nil | true + nil | 10 | nil | false + nil | 1 | 1 | true + nil | 1 | 10 | true + nil | 10 | 10 | false + end + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#previous_page?' do + subject { connection.previous_page? } + + where(:after, :last, :max_page_size, :result) do + nil | nil | nil | false + 1 | nil | nil | true + nil | 1 | nil | true + nil | 10 | nil | false + nil | 1 | 1 | true + nil | 1 | 10 | true + nil | 10 | 10 | false + end + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#cursor_for' do + let(:item) { items[0] } + let(:expected_result) do + GitlabSchema.cursor_encoder.encode( + Gitlab::Json.dump(id: item.id.to_s), + nonce: true + ) + end + + subject { connection.cursor_for(item) } + + it { is_expected.to eq(expected_result) } + + context 'with a BatchLoader::GraphQL item' do + let_it_be(:user) { create(:user) } + + let(:item) { ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::User, user.id).find } + let(:expected_result) do + GitlabSchema.cursor_encoder.encode( + Gitlab::Json.dump(id: user.id.to_s), + nonce: true + ) + end + + it { is_expected.to eq(expected_result) } + end + end + + describe '#dup' do + subject { connection.dup } + + it 'properly handles items duplication' do + connection2 = subject + + connection2 << create(:package_build_info) + + expect(connection.items).not_to eq(connection2.items) + end + end +end diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb index 0741088c915..86e7d4e344c 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_generic_keyset_spec.rb @@ -19,8 +19,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'last_repository_check_at', column_expression: Project.arel_table[:last_repository_check_at], - order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc), - reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc), + order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last, + reversed_order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last, order_direction: :asc, nullable: :nulls_last, distinct: false) @@ -30,8 +30,8 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'last_repository_check_at', column_expression: Project.arel_table[:last_repository_check_at], - order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_last_order('last_repository_check_at', :asc), + order_expression: Project.arel_table[:last_repository_check_at].desc.nulls_last, + reversed_order_expression: Project.arel_table[:last_repository_check_at].asc.nulls_last, order_direction: :desc, nullable: :nulls_last, distinct: false) @@ -256,11 +256,6 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do end end - # rubocop: disable RSpec/EmptyExampleGroup - context 'when ordering uses LOWER' do - end - # rubocop: enable RSpec/EmptyExampleGroup - context 'when ordering by similarity' do let_it_be(:project1) { create(:project, name: 'test') } let_it_be(:project2) { create(:project, name: 'testing') } diff --git a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb index b511a294f97..f31ec6c09fd 100644 --- a/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb +++ b/spec/lib/gitlab/graphql/pagination/keyset/connection_spec.rb @@ -77,6 +77,17 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do expect(decoded_cursor(cursor)).to eq('id' => project.id.to_s) end + context 'when SimpleOrderBuilder cannot build keyset paginated query' do + it 'increments the `old_keyset_pagination_usage` counter', :prometheus do + expect(Gitlab::Pagination::Keyset::SimpleOrderBuilder).to receive(:build).and_return([false, nil]) + + decoded_cursor(cursor) + + counter = Gitlab::Metrics.registry.get(:old_keyset_pagination_usage) + expect(counter.get(model: 'Project')).to eq(1) + end + end + context 'when an order is specified' do let(:nodes) { Project.order(:updated_at) } @@ -222,91 +233,97 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do end end - context 'when multiple orders with nil values are defined' do - let!(:project1) { create(:project, last_repository_check_at: 10.days.ago) } # Asc: project5 Desc: project3 - let!(:project2) { create(:project, last_repository_check_at: nil) } # Asc: project1 Desc: project1 - let!(:project3) { create(:project, last_repository_check_at: 5.days.ago) } # Asc: project3 Desc: project5 - let!(:project4) { create(:project, last_repository_check_at: nil) } # Asc: project2 Desc: project2 - let!(:project5) { create(:project, last_repository_check_at: 20.days.ago) } # Asc: project4 Desc: project4 + context 'when ordering uses LOWER' do + let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4 + let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2 + let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3 + let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5 + let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1 context 'when ascending' do let(:nodes) do - Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :asc).order(id: :asc) + Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc) end - let(:ascending_nodes) { [project5, project1, project3, project2, project4] } + let(:ascending_nodes) { [project1, project5, project3, project2, project4] } it_behaves_like 'nodes are in ascending order' - - context 'when before cursor value is NULL' do - let(:arguments) { { before: encoded_cursor(project4) } } - - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project5, project1, project3, project2]) - end - end - - context 'when after cursor value is NULL' do - let(:arguments) { { after: encoded_cursor(project2) } } - - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project4]) - end - end end context 'when descending' do let(:nodes) do - Project.order(Arel.sql('projects.last_repository_check_at IS NULL')).order(last_repository_check_at: :desc).order(id: :asc) + Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc) end - let(:descending_nodes) { [project3, project1, project5, project2, project4] } + let(:descending_nodes) { [project4, project2, project3, project5, project1] } it_behaves_like 'nodes are in descending order' + end + end - context 'when before cursor value is NULL' do - let(:arguments) { { before: encoded_cursor(project4) } } + context 'NULLS order' do + using RSpec::Parameterized::TableSyntax - it 'returns all projects before the cursor' do - expect(subject.sliced_nodes).to eq([project3, project1, project5, project2]) - end - end + let_it_be(:issue1) { create(:issue, relative_position: nil) } + let_it_be(:issue2) { create(:issue, relative_position: 100) } + let_it_be(:issue3) { create(:issue, relative_position: 200) } + let_it_be(:issue4) { create(:issue, relative_position: nil) } + let_it_be(:issue5) { create(:issue, relative_position: 300) } + + context 'when ascending NULLS LAST (ties broken by id DESC implicitly)' do + let(:ascending_nodes) { [issue2, issue3, issue5, issue4, issue1] } - context 'when after cursor value is NULL' do - let(:arguments) { { after: encoded_cursor(project2) } } + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_last) } + ] + end - it 'returns all projects after the cursor' do - expect(subject.sliced_nodes).to eq([project4]) - end + with_them do + it_behaves_like 'nodes are in ascending order' end end - end - context 'when ordering uses LOWER' do - let!(:project1) { create(:project, name: 'A') } # Asc: project1 Desc: project4 - let!(:project2) { create(:project, name: 'c') } # Asc: project5 Desc: project2 - let!(:project3) { create(:project, name: 'b') } # Asc: project3 Desc: project3 - let!(:project4) { create(:project, name: 'd') } # Asc: project2 Desc: project5 - let!(:project5) { create(:project, name: 'a') } # Asc: project4 Desc: project1 + context 'when descending NULLS LAST (ties broken by id DESC implicitly)' do + let(:descending_nodes) { [issue5, issue3, issue2, issue4, issue1] } - context 'when ascending' do - let(:nodes) do - Project.order(Arel::Table.new(:projects)['name'].lower.asc).order(id: :asc) + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_last) } +] end - let(:ascending_nodes) { [project1, project5, project3, project2, project4] } - - it_behaves_like 'nodes are in ascending order' + with_them do + it_behaves_like 'nodes are in descending order' + end end - context 'when descending' do - let(:nodes) do - Project.order(Arel::Table.new(:projects)['name'].lower.desc).order(id: :desc) + context 'when ascending NULLS FIRST with a tie breaker' do + let(:ascending_nodes) { [issue1, issue4, issue2, issue3, issue5] } + + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc) } +] end - let(:descending_nodes) { [project4, project2, project3, project5, project1] } + with_them do + it_behaves_like 'nodes are in ascending order' + end + end - it_behaves_like 'nodes are in descending order' + context 'when descending NULLS FIRST with a tie breaker' do + let(:descending_nodes) { [issue1, issue4, issue5, issue3, issue2] } + + where(:nodes) do + [ + lazy { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } +] + end + + with_them do + it_behaves_like 'nodes are in descending order' + end end end diff --git a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb index f5ee8eba8bc..676396697fb 100644 --- a/spec/lib/gitlab/hook_data/issuable_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/issuable_builder_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do let_it_be(:user) { create(:user) } # This shared example requires a `builder` and `user` variable - shared_examples 'issuable hook data' do |kind| + shared_examples 'issuable hook data' do |kind, hook_data_issuable_builder_class| let(:data) { builder.build(user: user) } include_examples 'project hook data' do @@ -20,7 +20,7 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do expect(data[:object_kind]).to eq(kind) expect(data[:user]).to eq(user.hook_attrs) expect(data[:project]).to eq(builder.issuable.project.hook_attrs) - expect(data[:object_attributes]).to eq(builder.issuable.hook_attrs) + expect(data[:object_attributes]).to eq(hook_data_issuable_builder_class.new(issuable).build) expect(data[:changes]).to eq({}) expect(data[:repository]).to eq(builder.issuable.project.hook_attrs.slice(:name, :url, :description, :homepage)) end @@ -95,12 +95,12 @@ RSpec.describe Gitlab::HookData::IssuableBuilder do end describe '#build' do - it_behaves_like 'issuable hook data', 'issue' do + it_behaves_like 'issuable hook data', 'issue', Gitlab::HookData::IssueBuilder do let(:issuable) { create(:issue, description: 'A description') } let(:builder) { described_class.new(issuable) } end - it_behaves_like 'issuable hook data', 'merge_request' do + it_behaves_like 'issuable hook data', 'merge_request', Gitlab::HookData::MergeRequestBuilder do let(:issuable) { create(:merge_request, description: 'A description') } let(:builder) { described_class.new(issuable) } end diff --git a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb index ddd681f75f0..771fc0218e2 100644 --- a/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb +++ b/spec/lib/gitlab/hook_data/merge_request_builder_spec.rb @@ -62,6 +62,7 @@ RSpec.describe Gitlab::HookData::MergeRequestBuilder do expect(data).to include(:human_time_estimate) expect(data).to include(:human_total_time_spent) expect(data).to include(:human_time_change) + expect(data).to include(:labels) end context 'when the MR has an image in the description' do diff --git a/spec/lib/gitlab/http_connection_adapter_spec.rb b/spec/lib/gitlab/http_connection_adapter_spec.rb index e9e517f1fe6..cde8376febd 100644 --- a/spec/lib/gitlab/http_connection_adapter_spec.rb +++ b/spec/lib/gitlab/http_connection_adapter_spec.rb @@ -27,16 +27,6 @@ RSpec.describe Gitlab::HTTPConnectionAdapter do end end - context 'with header_read_timeout_buffered_io feature disabled' do - before do - stub_feature_flags(header_read_timeout_buffered_io: false) - end - - it 'uses the regular Net::HTTP class' do - expect(connection).to be_a(Net::HTTP) - end - end - context 'when local requests are allowed' do let(:options) { { allow_local_requests: true } } diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 29a19e4cafd..730f9035293 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -665,6 +665,7 @@ protected_environments: - project - group - deploy_access_levels +- approval_rules deploy_access_levels: - protected_environment - user diff --git a/spec/lib/gitlab/import_export/command_line_util_spec.rb b/spec/lib/gitlab/import_export/command_line_util_spec.rb index 59d97357045..f47f1ab58a8 100644 --- a/spec/lib/gitlab/import_export/command_line_util_spec.rb +++ b/spec/lib/gitlab/import_export/command_line_util_spec.rb @@ -114,7 +114,7 @@ RSpec.describe Gitlab::ImportExport::CommandLineUtil do end end - %w[MOVED_PERMANENTLY FOUND TEMPORARY_REDIRECT].each do |code| + %w[MOVED_PERMANENTLY FOUND SEE_OTHER TEMPORARY_REDIRECT].each do |code| context "with a redirect status code #{code}" do let(:status) { HTTP::Status.const_get(code, false) } diff --git a/spec/lib/gitlab/import_export/duration_measuring_spec.rb b/spec/lib/gitlab/import_export/duration_measuring_spec.rb new file mode 100644 index 00000000000..cf8b6060741 --- /dev/null +++ b/spec/lib/gitlab/import_export/duration_measuring_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +RSpec.describe Gitlab::ImportExport::DurationMeasuring do + subject do + Class.new do + include Gitlab::ImportExport::DurationMeasuring + + def test + with_duration_measuring do + 'test' + end + end + end.new + end + + it 'measures method execution duration' do + subject.test + + expect(subject.duration_s).not_to be_nil + end + + describe '#with_duration_measuring' do + it 'yields control' do + expect { |block| subject.with_duration_measuring(&block) }.to yield_control + end + + it 'returns result of the yielded block' do + return_value = 'return_value' + + expect(subject.with_duration_measuring { return_value }).to eq(return_value) + 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 ba1cccf87ce..03f522ae490 100644 --- a/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb +++ b/spec/lib/gitlab/import_export/json/streaming_serializer_spec.rb @@ -115,7 +115,7 @@ RSpec.describe Gitlab::ImportExport::Json::StreamingSerializer do end it 'orders exported issues by custom column(relative_position)' do - expected_issues = exportable.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).map(&:to_json) + expected_issues = exportable.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).map(&:to_json) expect(json_writer).to receive(:write_relation_array).with(exportable_path, :issues, expected_issues) diff --git a/spec/lib/gitlab/import_export/version_checker_spec.rb b/spec/lib/gitlab/import_export/version_checker_spec.rb index 8b39330656f..9e69e04b17c 100644 --- a/spec/lib/gitlab/import_export/version_checker_spec.rb +++ b/spec/lib/gitlab/import_export/version_checker_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true require 'spec_helper' -include ImportExport::CommonUtil RSpec.describe Gitlab::ImportExport::VersionChecker do + include ImportExport::CommonUtil + let!(:shared) { Gitlab::ImportExport::Shared.new(nil) } describe 'bundle a project Git repo' do diff --git a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb index 2f3489edcd8..3a281574563 100644 --- a/spec/lib/gitlab/insecure_key_fingerprint_spec.rb +++ b/spec/lib/gitlab/insecure_key_fingerprint_spec.rb @@ -10,16 +10,9 @@ RSpec.describe Gitlab::InsecureKeyFingerprint do 'Jw0=' end - let(:fingerprint) { "3f:a2:ee:de:b5:de:53:c3:aa:2f:9c:45:24:4c:47:7b" } let(:fingerprint_sha256) { "MQHWhS9nhzUezUdD42ytxubZoBKrZLbyBZzxCkmnxXc" } - describe "#fingerprint" do - it "generates the key's fingerprint" do - expect(described_class.new(key.split[1]).fingerprint_md5).to eq(fingerprint) - end - end - - describe "#fingerprint" do + describe '#fingerprint_sha256' do it "generates the key's fingerprint" do expect(described_class.new(key.split[1]).fingerprint_sha256).to eq(fingerprint_sha256) end diff --git a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb index 02cc2eba4da..68f1c214cef 100644 --- a/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb +++ b/spec/lib/gitlab/legacy_github_import/project_creator_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do let(:namespace) { create(:group) } let(:repo) do - OpenStruct.new( + ActiveSupport::InheritableOptions.new( login: 'vim', name: 'vim', full_name: 'asd/vim', @@ -21,7 +21,7 @@ RSpec.describe Gitlab::LegacyGithubImport::ProjectCreator do namespace.add_owner(user) expect_next_instance_of(Project) do |project| - expect(project).to receive(:add_import_job) + allow(project).to receive(:add_import_job) end end diff --git a/spec/lib/gitlab/metrics/rails_slis_spec.rb b/spec/lib/gitlab/metrics/rails_slis_spec.rb index 0c77dc9f582..2ba06316507 100644 --- a/spec/lib/gitlab/metrics/rails_slis_spec.rb +++ b/spec/lib/gitlab/metrics/rails_slis_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Gitlab::Metrics::RailsSlis do end allow(Gitlab::RequestEndpoints).to receive(:all_api_endpoints).and_return([api_route]) - allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'show']]) + allow(Gitlab::RequestEndpoints).to receive(:all_controller_actions).and_return([[ProjectsController, 'index']]) allow(Gitlab::Graphql::KnownOperations).to receive(:default).and_return(Gitlab::Graphql::KnownOperations.new(%w(foo bar))) end @@ -22,13 +22,13 @@ RSpec.describe Gitlab::Metrics::RailsSlis do request_urgency: :default }, { - endpoint_id: "ProjectsController#show", + endpoint_id: "ProjectsController#index", feature_category: :projects, request_urgency: :default } ] - possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown', 'graphql:anonymous'].map do |endpoint_id| + possible_graphql_labels = ['graphql:foo', 'graphql:bar', 'graphql:unknown'].map do |endpoint_id| { endpoint_id: endpoint_id, feature_category: nil, diff --git a/spec/lib/gitlab/omniauth_initializer_spec.rb b/spec/lib/gitlab/omniauth_initializer_spec.rb index 8b959cf787f..c91b14a33ba 100644 --- a/spec/lib/gitlab/omniauth_initializer_spec.rb +++ b/spec/lib/gitlab/omniauth_initializer_spec.rb @@ -309,4 +309,16 @@ RSpec.describe Gitlab::OmniauthInitializer do subject.execute([conf]) end end + + describe '.full_host' do + subject { described_class.full_host.call({}) } + + let(:base_url) { 'http://localhost/test' } + + before do + allow(Settings).to receive(:gitlab).and_return({ 'base_url' => base_url }) + end + + it { is_expected.to eq(base_url) } + end end diff --git a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb index 69384e0c501..778244677ef 100644 --- a/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/column_order_definition_spec.rb @@ -140,8 +140,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do described_class.new( attribute_name: :name, column_expression: Project.arel_table[:name], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, # null values are always last distinct: false @@ -161,8 +161,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do described_class.new( attribute_name: :name, column_expression: Project.arel_table[:name], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: true, distinct: false @@ -175,8 +175,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do described_class.new( attribute_name: :name, column_expression: Project.arel_table[:name], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, distinct: true @@ -191,8 +191,8 @@ RSpec.describe Gitlab::Pagination::Keyset::ColumnOrderDefinition do described_class.new( attribute_name: :name, column_expression: Project.arel_table[:name], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', :desc), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', :asc), + order_expression: MergeRequest::Metrics.arel_table[:merged_at].desc.nulls_last, + reversed_order_expression: MergeRequest::Metrics.arel_table[:merged_at].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, # null values are always last distinct: false diff --git a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb index 58db22e5a9c..9f2ac9a953d 100644 --- a/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/in_operator_optimization/query_builder_spec.rb @@ -24,12 +24,12 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder let_it_be(:issues) do [ create(:issue, project: project_1, created_at: three_weeks_ago, relative_position: 5), - create(:issue, project: project_1, created_at: two_weeks_ago), + create(:issue, project: project_1, created_at: two_weeks_ago, relative_position: nil), create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: 15), - create(:issue, project: project_2, created_at: two_weeks_ago), - create(:issue, project: project_3, created_at: four_weeks_ago), + create(:issue, project: project_2, created_at: two_weeks_ago, relative_position: nil), + create(:issue, project: project_3, created_at: four_weeks_ago, relative_position: nil), create(:issue, project: project_4, created_at: five_weeks_ago, relative_position: 10), - create(:issue, project: project_5, created_at: four_weeks_ago) + create(:issue, project: project_5, created_at: four_weeks_ago, relative_position: nil) ] end @@ -121,8 +121,8 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: :relative_position, column_expression: Issue.arel_table[:relative_position], - order_expression: Gitlab::Database.nulls_last_order('relative_position', :desc), - reversed_order_expression: Gitlab::Database.nulls_first_order('relative_position', :asc), + order_expression: Issue.arel_table[:relative_position].desc.nulls_last, + reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first, order_direction: :desc, nullable: :nulls_last, distinct: false @@ -155,6 +155,31 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder it_behaves_like 'correct ordering examples' end + + context 'with condition "relative_position IS NULL"' do + let(:base_scope) { Issue.where(relative_position: nil) } + let(:scope) { base_scope.order(order) } + + let(:in_operator_optimization_options) do + { + array_scope: Project.where(namespace_id: top_level_group.self_and_descendants.select(:id)).select(:id), + array_mapping_scope: -> (id_expression) { Issue.merge(base_scope.dup).where(Issue.arel_table[:project_id].eq(id_expression)) }, + finder_query: -> (_relative_position_expression, id_expression) { Issue.where(Issue.arel_table[:id].eq(id_expression)) } + } + end + + context 'when iterating records one by one' do + let(:batch_size) { 1 } + + it_behaves_like 'correct ordering examples' + end + + context 'when iterating records with LIMIT 3' do + let(:batch_size) { 3 } + + it_behaves_like 'correct ordering examples' + end + end end context 'when ordering by issues.created_at DESC, issues.id ASC' do @@ -239,7 +264,7 @@ RSpec.describe Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder end it 'raises error when unsupported scope is passed' do - scope = Issue.order(Issue.arel_table[:id].lower.desc) + scope = Issue.order(Arel::Nodes::NamedFunction.new('UPPER', [Issue.arel_table[:id]])) options = { scope: scope, diff --git a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb index 09cbca2c1cb..d62d20d2d2c 100644 --- a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb @@ -19,8 +19,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: column, column_expression: klass.arel_table[column], - order_expression: ::Gitlab::Database.nulls_order(column, direction, nulls_position), - reversed_order_expression: ::Gitlab::Database.nulls_order(column, reverse_direction, reverse_nulls_position), + order_expression: klass.arel_table[column].public_send(direction).public_send(nulls_position), # rubocop:disable GitlabSecurity/PublicSend + reversed_order_expression: klass.arel_table[column].public_send(reverse_direction).public_send(reverse_nulls_position), # rubocop:disable GitlabSecurity/PublicSend order_direction: direction, nullable: nulls_position, distinct: false @@ -99,7 +99,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_last).order(id: :asc).pluck(:relative_position, :id)) end end @@ -111,7 +111,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :desc).pluck(:relative_position, :id)) end end @@ -123,7 +123,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_first_order('relative_position', 'ASC')).order(id: :asc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].asc.nulls_first).order(id: :asc).pluck(:relative_position, :id)) end end @@ -136,7 +136,7 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do iterator.each_batch(of: 2) { |rel| positions.concat(rel.pluck(:relative_position, :id)) } - expect(positions).to eq(project.issues.reorder(::Gitlab::Database.nulls_last_order('relative_position', 'DESC')).order(id: :desc).pluck(:relative_position, :id)) + expect(positions).to eq(project.issues.reorder(Issue.arel_table[:relative_position].desc.nulls_last).order(id: :desc).pluck(:relative_position, :id)) end end diff --git a/spec/lib/gitlab/pagination/keyset/order_spec.rb b/spec/lib/gitlab/pagination/keyset/order_spec.rb index 1bed8e542a2..abbb3a21cd4 100644 --- a/spec/lib/gitlab/pagination/keyset/order_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/order_spec.rb @@ -262,8 +262,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'year', column_expression: table['year'], - order_expression: Gitlab::Database.nulls_last_order('year', :asc), - reversed_order_expression: Gitlab::Database.nulls_first_order('year', :desc), + order_expression: table[:year].asc.nulls_last, + reversed_order_expression: table[:year].desc.nulls_first, order_direction: :asc, nullable: :nulls_last, distinct: false @@ -271,8 +271,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'month', column_expression: table['month'], - order_expression: Gitlab::Database.nulls_last_order('month', :asc), - reversed_order_expression: Gitlab::Database.nulls_first_order('month', :desc), + order_expression: table[:month].asc.nulls_last, + reversed_order_expression: table[:month].desc.nulls_first, order_direction: :asc, nullable: :nulls_last, distinct: false @@ -328,8 +328,8 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'year', column_expression: table['year'], - order_expression: Gitlab::Database.nulls_first_order('year', :asc), - reversed_order_expression: Gitlab::Database.nulls_last_order('year', :desc), + order_expression: table[:year].asc.nulls_first, + reversed_order_expression: table[:year].desc.nulls_last, order_direction: :asc, nullable: :nulls_first, distinct: false @@ -337,9 +337,9 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( attribute_name: 'month', column_expression: table['month'], - order_expression: Gitlab::Database.nulls_first_order('month', :asc), + order_expression: table[:month].asc.nulls_first, order_direction: :asc, - reversed_order_expression: Gitlab::Database.nulls_last_order('month', :desc), + reversed_order_expression: table[:month].desc.nulls_last, nullable: :nulls_first, distinct: false ), @@ -441,6 +441,47 @@ RSpec.describe Gitlab::Pagination::Keyset::Order do end end + context 'when ordering by the named function LOWER' do + let(:order) do + Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'title', + column_expression: Arel::Nodes::NamedFunction.new("LOWER", [table['title'].desc]), + order_expression: table['title'].lower.desc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + column_expression: table['id'], + order_expression: table['id'].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + + let(:table_data) do + <<-SQL + VALUES (1, 'A') + SQL + end + + let(:query) do + <<-SQL + SELECT id, title + FROM (#{table_data}) my_table (id, title) + ORDER BY #{order}; + SQL + end + + subject { run_query(query) } + + it "uses downcased value for encoding and decoding a cursor" do + expect(order.cursor_attributes_for_node(subject.first)['title']).to eq("a") + end + end + context 'when the passed cursor values do not match with the order definition' do let(:order) do Gitlab::Pagination::Keyset::Order.build([ diff --git a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb index 5af86cb2dc0..4f1d380ab0a 100644 --- a/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/simple_order_builder_spec.rb @@ -5,6 +5,7 @@ require 'spec_helper' RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do let(:ordered_scope) { described_class.build(scope).first } let(:order_object) { Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(ordered_scope) } + let(:column_definition) { order_object.column_definitions.first } subject(:sql_with_order) { ordered_scope.to_sql } @@ -16,11 +17,25 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do end it 'sets the column definition distinct and not nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition).to be_not_nullable expect(column_definition).to be_distinct end + + context "when the order scope's model uses default_scope" do + let(:scope) do + model = Class.new(ApplicationRecord) do + self.table_name = 'events' + + default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope + end + + model.reorder(nil) + end + + it 'orders by primary key' do + expect(sql_with_order).to end_with('ORDER BY "events"."id" DESC') + end + end end context 'when primary key order present' do @@ -39,8 +54,6 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do end it 'sets the column definition for created_at non-distinct and nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition.attribute_name).to eq('created_at') expect(column_definition.nullable?).to eq(true) # be_nullable calls non_null? method for some reason expect(column_definition).not_to be_distinct @@ -59,14 +72,80 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do let(:scope) { Project.where(id: [1, 2, 3]).order(namespace_id: :asc, id: :asc) } it 'sets the column definition for namespace_id non-distinct and non-nullable' do - column_definition = order_object.column_definitions.first - expect(column_definition.attribute_name).to eq('namespace_id') expect(column_definition).to be_not_nullable expect(column_definition).not_to be_distinct end end + context 'when ordering by a column with the lower named function' do + let(:scope) { Project.where(id: [1, 2, 3]).order(Project.arel_table[:name].lower.desc) } + + it 'sets the column definition for name' do + expect(column_definition.attribute_name).to eq('name') + expect(column_definition.column_expression.expressions.first.name).to eq('name') + expect(column_definition.column_expression.name).to eq('LOWER') + end + + it 'adds extra primary key order as tie-breaker' do + expect(sql_with_order).to end_with('ORDER BY LOWER("projects"."name") DESC, "projects"."id" DESC') + end + end + + context "NULLS order given as as an Arel literal" do + context 'when NULLS LAST order is given without a tie-breaker' do + let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('created_at') + end + + it 'orders by primary key' do + expect(sql_with_order) + .to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC') + end + end + + context 'when NULLS FIRST order is given with a tie-breaker' do + let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('relative_position') + end + + it 'orders by the given primary key' do + expect(sql_with_order) + .to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC') + end + end + end + + context "NULLS order given as as an Arel node" do + context 'when NULLS LAST order is given without a tie-breaker' do + let(:scope) { Project.order(Project.arel_table[:created_at].asc.nulls_last) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('created_at') + end + + it 'orders by primary key' do + expect(sql_with_order).to end_with('ORDER BY "projects"."created_at" ASC NULLS LAST, "projects"."id" DESC') + end + end + + context 'when NULLS FIRST order is given with a tie-breaker' do + let(:scope) { Issue.order(Issue.arel_table[:relative_position].desc.nulls_first).order(id: :asc) } + + it 'sets the column definition for created_at appropriately' do + expect(column_definition.attribute_name).to eq('relative_position') + end + + it 'orders by the given primary key' do + expect(sql_with_order).to end_with('ORDER BY "issues"."relative_position" DESC NULLS FIRST, "issues"."id" ASC') + end + end + end + context 'return :unable_to_order symbol when order cannot be built' do subject(:success) { described_class.build(scope).last } @@ -76,10 +155,20 @@ RSpec.describe Gitlab::Pagination::Keyset::SimpleOrderBuilder do it { is_expected.to eq(false) } end - context 'when NULLS LAST order is given' do - let(:scope) { Project.order(::Gitlab::Database.nulls_last_order('created_at', 'ASC')) } + context 'when an invalid NULLS order is given' do + using RSpec::Parameterized::TableSyntax - it { is_expected.to eq(false) } + where(:scope) do + [ + lazy { Project.order(Arel.sql('projects.updated_at created_at Asc Nulls Last')) }, + lazy { Project.order(Arel.sql('projects.created_at ZZZ NULLS FIRST')) }, + lazy { Project.order(Arel.sql('projects.relative_position ASC NULLS LAST')) } + ] + end + + with_them do + it { is_expected.to eq(false) } + end end context 'when more than 2 columns are given for the order' do diff --git a/spec/lib/gitlab/pagination/offset_pagination_spec.rb b/spec/lib/gitlab/pagination/offset_pagination_spec.rb index f8d50fbc517..ebbd207cc11 100644 --- a/spec/lib/gitlab/pagination/offset_pagination_spec.rb +++ b/spec/lib/gitlab/pagination/offset_pagination_spec.rb @@ -66,70 +66,50 @@ RSpec.describe Gitlab::Pagination::OffsetPagination do let(:query) { base_query.merge(page: 1, per_page: 2) } - context 'when the api_kaminari_count_with_limit feature flag is unset' do - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' - end - - context 'when the api_kaminari_count_with_limit feature flag is disabled' do + context 'when resources count is less than MAX_COUNT_LIMIT' do before do - stub_feature_flags(api_kaminari_count_with_limit: false) + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) end it_behaves_like 'paginated response' it_behaves_like 'response with pagination headers' end - context 'when the api_kaminari_count_with_limit feature flag is enabled' do + context 'when resources count is more than MAX_COUNT_LIMIT' do before do - stub_feature_flags(api_kaminari_count_with_limit: true) - end - - context 'when resources count is less than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 4) - end - - it_behaves_like 'paginated response' - it_behaves_like 'response with pagination headers' + stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2) end - context 'when resources count is more than MAX_COUNT_LIMIT' do - before do - stub_const("::Kaminari::ActiveRecordRelationMethods::MAX_COUNT_LIMIT", 2) - end - - it_behaves_like 'paginated response' - - it 'does not return the X-Total and X-Total-Pages headers' do - expect_no_header('X-Total') - expect_no_header('X-Total-Pages') - expect_header('X-Per-Page', '2') - expect_header('X-Page', '1') - expect_header('X-Next-Page', '2') - expect_header('X-Prev-Page', '') - - expect_header('Link', anything) do |_key, val| - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) - expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) - expect(val).not_to include('rel="last"') - expect(val).not_to include('rel="prev"') - end - - subject.paginate(resource) - end - end + it_behaves_like 'paginated response' - it 'does not return the total headers when excluding them' do + it 'does not return the X-Total and X-Total-Pages headers' do expect_no_header('X-Total') expect_no_header('X-Total-Pages') expect_header('X-Per-Page', '2') expect_header('X-Page', '1') + expect_header('X-Next-Page', '2') + expect_header('X-Prev-Page', '') - paginator.paginate(resource, exclude_total_headers: true) + expect_header('Link', anything) do |_key, val| + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 1).to_query}>; rel="first")) + expect(val).to include(%Q(<#{incoming_api_projects_url}?#{query.merge(page: 2).to_query}>; rel="next")) + expect(val).not_to include('rel="last"') + expect(val).not_to include('rel="prev"') + end + + subject.paginate(resource) end end + it 'does not return the total headers when excluding them' do + expect_no_header('X-Total') + expect_no_header('X-Total-Pages') + expect_header('X-Per-Page', '2') + expect_header('X-Page', '1') + + paginator.paginate(resource, exclude_total_headers: true) + end + context 'when resource already paginated' do let(:resource) { Project.all.page(1).per(1) } diff --git a/spec/lib/gitlab/patch/database_config_spec.rb b/spec/lib/gitlab/patch/database_config_spec.rb new file mode 100644 index 00000000000..d6f36ab86d5 --- /dev/null +++ b/spec/lib/gitlab/patch/database_config_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Patch::DatabaseConfig do + it 'module is included' do + expect(Rails::Application::Configuration).to include(described_class) + end + + describe 'config/database.yml' do + let(:configuration) { Rails::Application::Configuration.new(Rails.root) } + + before do + allow(File).to receive(:exist?).and_call_original + allow(File).to receive(:exist?).with(Rails.root.join("config/database_geo.yml")).and_return(false) + + # The `AS::ConfigurationFile` calls `read` in `def initialize` + # thus we cannot use `expect_next_instance_of` + # rubocop:disable RSpec/AnyInstanceOf + expect_any_instance_of(ActiveSupport::ConfigurationFile) + .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) + # rubocop:enable RSpec/AnyInstanceOf + end + + shared_examples 'hash containing main: connection name' do + it 'returns a hash containing only main:' do + database_configuration = configuration.database_configuration + + expect(database_configuration).to match( + "production" => { "main" => a_hash_including("adapter") }, + "development" => { "main" => a_hash_including("adapter" => "postgresql") }, + "test" => { "main" => a_hash_including("adapter" => "postgresql") } + ) + end + end + + context 'when a new syntax is used' do + let(:database_yml) do + <<-EOS + production: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + main: + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is not legacy one' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(false) + end + end + + context 'when a legacy syntax is used' do + let(:database_yml) do + <<-EOS + production: + adapter: postgresql + encoding: unicode + database: gitlabhq_production + username: git + password: "secure password" + host: localhost + + development: + adapter: postgresql + encoding: unicode + database: gitlabhq_development + username: postgres + password: "secure password" + host: localhost + variables: + statement_timeout: 15s + + test: &test + adapter: postgresql + encoding: unicode + database: gitlabhq_test + username: postgres + password: + host: localhost + prepared_statements: false + variables: + statement_timeout: 15s + EOS + end + + include_examples 'hash containing main: connection name' + + it 'configuration is legacy' do + configuration.database_configuration + + expect(configuration.uses_legacy_database_config).to eq(true) + end + end + end +end diff --git a/spec/lib/gitlab/patch/legacy_database_config_spec.rb b/spec/lib/gitlab/patch/legacy_database_config_spec.rb deleted file mode 100644 index b87e16f31ae..00000000000 --- a/spec/lib/gitlab/patch/legacy_database_config_spec.rb +++ /dev/null @@ -1,126 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Gitlab::Patch::LegacyDatabaseConfig do - it 'module is included' do - expect(Rails::Application::Configuration).to include(described_class) - end - - describe 'config/database.yml' do - let(:configuration) { Rails::Application::Configuration.new(Rails.root) } - - before do - allow(File).to receive(:exist?).and_call_original - allow(File).to receive(:exist?).with(Rails.root.join("config/database_geo.yml")).and_return(false) - - # The `AS::ConfigurationFile` calls `read` in `def initialize` - # thus we cannot use `expect_next_instance_of` - # rubocop:disable RSpec/AnyInstanceOf - expect_any_instance_of(ActiveSupport::ConfigurationFile) - .to receive(:read).with(Rails.root.join('config/database.yml')).and_return(database_yml) - # rubocop:enable RSpec/AnyInstanceOf - end - - shared_examples 'hash containing main: connection name' do - it 'returns a hash containing only main:' do - database_configuration = configuration.database_configuration - - expect(database_configuration).to match( - "production" => { "main" => a_hash_including("adapter") }, - "development" => { "main" => a_hash_including("adapter" => "postgresql") }, - "test" => { "main" => a_hash_including("adapter" => "postgresql") } - ) - end - end - - context 'when a new syntax is used' do - let(:database_yml) do - <<-EOS - production: - main: - adapter: postgresql - encoding: unicode - database: gitlabhq_production - username: git - password: "secure password" - host: localhost - - development: - main: - adapter: postgresql - encoding: unicode - database: gitlabhq_development - username: postgres - password: "secure password" - host: localhost - variables: - statement_timeout: 15s - - test: &test - main: - adapter: postgresql - encoding: unicode - database: gitlabhq_test - username: postgres - password: - host: localhost - prepared_statements: false - variables: - statement_timeout: 15s - EOS - end - - include_examples 'hash containing main: connection name' - - it 'configuration is not legacy one' do - configuration.database_configuration - - expect(configuration.uses_legacy_database_config).to eq(false) - end - end - - context 'when a legacy syntax is used' do - let(:database_yml) do - <<-EOS - production: - adapter: postgresql - encoding: unicode - database: gitlabhq_production - username: git - password: "secure password" - host: localhost - - development: - adapter: postgresql - encoding: unicode - database: gitlabhq_development - username: postgres - password: "secure password" - host: localhost - variables: - statement_timeout: 15s - - test: &test - adapter: postgresql - encoding: unicode - database: gitlabhq_test - username: postgres - password: - host: localhost - prepared_statements: false - variables: - statement_timeout: 15s - EOS - end - - include_examples 'hash containing main: connection name' - - it 'configuration is legacy' do - configuration.database_configuration - - expect(configuration.uses_legacy_database_config).to eq(true) - end - end - end -end diff --git a/spec/lib/gitlab/path_regex_spec.rb b/spec/lib/gitlab/path_regex_spec.rb index 9876387512b..e5fa7538515 100644 --- a/spec/lib/gitlab/path_regex_spec.rb +++ b/spec/lib/gitlab/path_regex_spec.rb @@ -557,7 +557,7 @@ RSpec.describe Gitlab::PathRegex do end it 'does not match other non-word characters' do - expect(subject.match('ruby:2.7.0')[0]).to eq('ruby') + expect(subject.match('image:1.0.0')[0]).to eq('image') end end diff --git a/spec/lib/gitlab/project_template_spec.rb b/spec/lib/gitlab/project_template_spec.rb index 05417e721c7..0ef52b63bc6 100644 --- a/spec/lib/gitlab/project_template_spec.rb +++ b/spec/lib/gitlab/project_template_spec.rb @@ -8,7 +8,7 @@ RSpec.describe Gitlab::ProjectTemplate do expected = %w[ rails spring express iosswift dotnetcore android gomicro gatsby hugo jekyll plainhtml gitbook - hexo sse_middleman gitpod_spring_petclinic nfhugo + hexo middleman gitpod_spring_petclinic nfhugo nfjekyll nfplainhtml nfgitbook nfhexo salesforcedx serverless_framework tencent_serverless_framework jsonnet cluster_management kotlin_native_linux diff --git a/spec/lib/gitlab/quick_actions/command_definition_spec.rb b/spec/lib/gitlab/quick_actions/command_definition_spec.rb index 73629ce3da2..8362c07baca 100644 --- a/spec/lib/gitlab/quick_actions/command_definition_spec.rb +++ b/spec/lib/gitlab/quick_actions/command_definition_spec.rb @@ -26,7 +26,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do describe "#noop?" do context "when the command has an action block" do before do - subject.action_block = proc { } + subject.action_block = proc {} end it "returns false" do @@ -42,7 +42,7 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do end describe "#available?" do - let(:opts) { OpenStruct.new(go: false) } + let(:opts) { ActiveSupport::InheritableOptions.new(go: false) } context "when the command has a condition block" do before do @@ -104,7 +104,8 @@ RSpec.describe Gitlab::QuickActions::CommandDefinition do end describe "#execute" do - let(:context) { OpenStruct.new(run: false, commands_executed_count: nil) } + let(:fake_context) { Struct.new(:run, :commands_executed_count, :received_arg) } + let(:context) { fake_context.new(false, nil, nil) } context "when the command is a noop" do it "doesn't execute the command" do diff --git a/spec/lib/gitlab/search_context/builder_spec.rb b/spec/lib/gitlab/search_context/builder_spec.rb index 079477115bb..a09115f3f21 100644 --- a/spec/lib/gitlab/search_context/builder_spec.rb +++ b/spec/lib/gitlab/search_context/builder_spec.rb @@ -43,7 +43,6 @@ RSpec.describe Gitlab::SearchContext::Builder, type: :controller do def be_search_context(project: nil, group: nil, snippets: [], ref: nil) group = project ? project.group : group snippets.compact! - ref = ref have_attributes( project: project, diff --git a/spec/lib/gitlab/security/scan_configuration_spec.rb b/spec/lib/gitlab/security/scan_configuration_spec.rb index 2e8a11dfda3..1760796c5a0 100644 --- a/spec/lib/gitlab/security/scan_configuration_spec.rb +++ b/spec/lib/gitlab/security/scan_configuration_spec.rb @@ -47,6 +47,16 @@ RSpec.describe ::Gitlab::Security::ScanConfiguration do it { is_expected.to be_nil } end + describe '#meta_info_path' do + subject { scan.meta_info_path } + + let(:configured) { true } + let(:available) { true } + let(:type) { :dast } + + it { is_expected.to be_nil } + end + describe '#can_enable_by_merge_request?' do subject { scan.can_enable_by_merge_request? } diff --git a/spec/lib/gitlab/seeder_spec.rb b/spec/lib/gitlab/seeder_spec.rb index 71d0a41ef98..a22d47cbfb3 100644 --- a/spec/lib/gitlab/seeder_spec.rb +++ b/spec/lib/gitlab/seeder_spec.rb @@ -3,6 +3,24 @@ require 'spec_helper' RSpec.describe Gitlab::Seeder do + describe Namespace do + subject { described_class } + + it 'has not_mass_generated scope' do + expect { Namespace.not_mass_generated }.to raise_error(NoMethodError) + + Gitlab::Seeder.quiet do + expect { Namespace.not_mass_generated }.not_to raise_error + end + end + + it 'includes NamespaceSeed module' do + Gitlab::Seeder.quiet do + is_expected.to include_module(Gitlab::Seeder::NamespaceSeed) + end + end + end + describe '.quiet' do let(:database_base_models) do { @@ -50,4 +68,13 @@ RSpec.describe Gitlab::Seeder do notification_service.new_note(note) end end + + describe '.log_message' do + it 'prepends timestamp to the logged message' do + freeze_time do + message = "some message." + expect { described_class.log_message(message) }.to output(/#{Time.current}: #{message}/).to_stdout + end + end + end end diff --git a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb index 3fbd207c2e1..ffa92126cc9 100644 --- a/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/server_metrics_spec.rb @@ -292,7 +292,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::ServerMetrics do if category feature_category category else - feature_category_not_owned! + feature_category :not_owned end def perform diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb index b9a13fd697e..3baa0c6f967 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/client_spec.rb @@ -28,7 +28,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Client do 'TestNotOwnedWithContextWorker' end - feature_category_not_owned! + feature_category :not_owned end end diff --git a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb index 377ff6fd166..05b328e55d3 100644 --- a/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb +++ b/spec/lib/gitlab/sidekiq_middleware/worker_context/server_spec.rb @@ -29,7 +29,7 @@ RSpec.describe Gitlab::SidekiqMiddleware::WorkerContext::Server do "NotOwnedWorker" end - feature_category_not_owned! + feature_category :not_owned end end diff --git a/spec/lib/gitlab/ssh_public_key_spec.rb b/spec/lib/gitlab/ssh_public_key_spec.rb index cf5d2c3b455..422b6f925a1 100644 --- a/spec/lib/gitlab/ssh_public_key_spec.rb +++ b/spec/lib/gitlab/ssh_public_key_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::SSHPublicKey, lib: true do +RSpec.describe Gitlab::SSHPublicKey, lib: true, fips_mode: false do let(:key) { attributes_for(:rsa_key_2048)[:key] } let(:public_key) { described_class.new(key) } @@ -19,6 +19,17 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do it { expect(described_class.technology(name).name).to eq(name) } it { expect(described_class.technology(name.to_s).name).to eq(name) } end + + context 'FIPS mode', :fips_mode do + where(:name) do + [:rsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] + end + + with_them do + it { expect(described_class.technology(name).name).to eq(name) } + it { expect(described_class.technology(name.to_s).name).to eq(name) } + end + end end describe '.supported_types' do @@ -27,6 +38,14 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do [:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] ) end + + context 'FIPS mode', :fips_mode do + it 'returns array with the names of supported technologies' do + expect(described_class.supported_types).to eq( + [:rsa, :dsa, :ecdsa, :ed25519, :ecdsa_sk, :ed25519_sk] + ) + end + end end describe '.supported_sizes(name)' do @@ -45,6 +64,24 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do it { expect(described_class.supported_sizes(name)).to eq(sizes) } it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } end + + context 'FIPS mode', :fips_mode do + where(:name, :sizes) do + [ + [:rsa, [3072, 4096]], + [:dsa, []], + [:ecdsa, [256, 384, 521]], + [:ed25519, [256]], + [:ecdsa_sk, [256]], + [:ed25519_sk, [256]] + ] + end + + with_them do + it { expect(described_class.supported_sizes(name)).to eq(sizes) } + it { expect(described_class.supported_sizes(name.to_s)).to eq(sizes) } + end + end end describe '.supported_algorithms' do @@ -60,6 +97,21 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do ) ) end + + context 'FIPS mode', :fips_mode do + it 'returns all supported algorithms' do + expect(described_class.supported_algorithms).to eq( + %w( + ssh-rsa + ssh-dss + ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521 + ssh-ed25519 + sk-ecdsa-sha2-nistp256@openssh.com + sk-ssh-ed25519@openssh.com + ) + ) + end + end end describe '.supported_algorithms_for_name' do @@ -80,6 +132,26 @@ RSpec.describe Gitlab::SSHPublicKey, lib: true do expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms) end end + + context 'FIPS mode', :fips_mode do + where(:name, :algorithms) do + [ + [:rsa, %w(ssh-rsa)], + [:dsa, %w(ssh-dss)], + [:ecdsa, %w(ecdsa-sha2-nistp256 ecdsa-sha2-nistp384 ecdsa-sha2-nistp521)], + [:ed25519, %w(ssh-ed25519)], + [:ecdsa_sk, %w(sk-ecdsa-sha2-nistp256@openssh.com)], + [:ed25519_sk, %w(sk-ssh-ed25519@openssh.com)] + ] + end + + with_them do + it "returns all supported algorithms for #{params[:name]}" do + expect(described_class.supported_algorithms_for_name(name)).to eq(algorithms) + expect(described_class.supported_algorithms_for_name(name.to_s)).to eq(algorithms) + end + end + end end describe '.sanitize(key_content)' do diff --git a/spec/lib/gitlab/suggestions/commit_message_spec.rb b/spec/lib/gitlab/suggestions/commit_message_spec.rb index 965960f0c3e..dcadc206715 100644 --- a/spec/lib/gitlab/suggestions/commit_message_spec.rb +++ b/spec/lib/gitlab/suggestions/commit_message_spec.rb @@ -3,7 +3,10 @@ require 'spec_helper' RSpec.describe Gitlab::Suggestions::CommitMessage do - def create_suggestion(file_path, new_line, to_content) + include ProjectForksHelper + using RSpec::Parameterized::TableSyntax + + def create_suggestion(merge_request, file_path, new_line, to_content) position = Gitlab::Diff::Position.new(old_path: file_path, new_path: file_path, old_line: nil, @@ -29,69 +32,111 @@ RSpec.describe Gitlab::Suggestions::CommitMessage do create(:project, :repository, path: 'project-1', name: 'Project_1') end - let_it_be(:merge_request) do + let_it_be(:forked_project) { fork_project(project, nil, repository: true) } + + let_it_be(:merge_request_same_project) do create(:merge_request, source_project: project, target_project: project) end - let_it_be(:suggestion_set) do - suggestion1 = create_suggestion('files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') - suggestion2 = create_suggestion('files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') - suggestion3 = create_suggestion('files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') + let_it_be(:merge_request_from_fork) do + create(:merge_request, source_project: forked_project, target_project: project) + end + + let_it_be(:suggestion_set_same_project) do + suggestion1 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') + suggestion2 = create_suggestion(merge_request_same_project, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') + suggestion3 = create_suggestion(merge_request_same_project, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') + + Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3]) + end + + let_it_be(:suggestion_set_forked_project) do + suggestion1 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 9, '*** SUGGESTION 1 ***') + suggestion2 = create_suggestion(merge_request_from_fork, 'files/ruby/popen.rb', 13, '*** SUGGESTION 2 ***') + suggestion3 = create_suggestion(merge_request_from_fork, 'files/ruby/regex.rb', 22, '*** SUGGESTION 3 ***') Gitlab::Suggestions::SuggestionSet.new([suggestion1, suggestion2, suggestion3]) end describe '#message' do - before do - # Updating the suggestion_commit_message on a project shared across specs - # avoids recreating the repository for each spec. - project.update!(suggestion_commit_message: message) - end + where(:suggestion_set) { [ref(:suggestion_set_same_project), ref(:suggestion_set_forked_project)] } + + with_them do + before do + # Updating the suggestion_commit_message on a project shared across specs + # avoids recreating the repository for each spec. + project.update!(suggestion_commit_message: message) + forked_project.update!(suggestion_commit_message: fork_message) + end + + let(:fork_message) { nil } - context 'when a custom commit message is not specified' do - let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' } + context 'when a custom commit message is not specified' do + let(:expected_message) { 'Apply 3 suggestion(s) to 2 file(s)' } - context 'and is nil' do - let(:message) { nil } + context 'and is nil' do + let(:message) { nil } - it 'uses the default commit message' do - expect(described_class - .new(user, suggestion_set) - .message).to eq(expected_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end end - end - context 'and is an empty string' do - let(:message) { '' } + context 'and is an empty string' do + let(:message) { '' } - it 'uses the default commit message' do - expect(described_class - .new(user, suggestion_set) - .message).to eq(expected_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end end - end - end - context 'when a custom commit message is specified' do - let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" } - let(:custom_message) { "hello there! i'm a cool custom commit message." } + context 'when a custom commit message is specified for forked project' do + let(:message) { nil } + let(:fork_message) { "I'm a sad message that will not be used :(" } - it 'shows the custom commit message' do - expect(Gitlab::Suggestions::CommitMessage - .new(user, suggestion_set, custom_message) - .message).to eq(custom_message) + it 'uses the default commit message' do + expect(described_class + .new(user, suggestion_set) + .message).to eq(expected_message) + end + end end - end - context 'is specified and includes all placeholders' do - let(:message) do - '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***' + context 'when a custom commit message is specified' do + let(:message) { "i'm a project message. a user's custom message takes precedence over me :(" } + let(:custom_message) { "hello there! i'm a cool custom commit message." } + + it 'shows the custom commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set, custom_message) + .message).to eq(custom_message) + end end - it 'generates a custom commit message' do - expect(Gitlab::Suggestions::CommitMessage - .new(user, suggestion_set) - .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + context 'is specified and includes all placeholders' do + let(:message) do + '*** %{branch_name} %{files_count} %{file_paths} %{project_name} %{project_path} %{user_full_name} %{username} %{suggestions_count} ***' + end + + it 'generates a custom commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set) + .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + end + + context 'when a custom commit message is specified for forked project' do + let(:fork_message) { "I'm a sad message that will not be used :(" } + + it 'uses the target project commit message' do + expect(Gitlab::Suggestions::CommitMessage + .new(user, suggestion_set) + .message).to eq('*** master 2 files/ruby/popen.rb, files/ruby/regex.rb Project_1 project-1 Test User test.user 3 ***') + end + end end end end diff --git a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb index 54d79a9d4ba..469646986e1 100644 --- a/spec/lib/gitlab/suggestions/suggestion_set_spec.rb +++ b/spec/lib/gitlab/suggestions/suggestion_set_spec.rb @@ -3,6 +3,9 @@ require 'spec_helper' RSpec.describe Gitlab::Suggestions::SuggestionSet do + include ProjectForksHelper + using RSpec::Parameterized::TableSyntax + def create_suggestion(file_path, new_line, to_content) position = Gitlab::Diff::Position.new(old_path: file_path, new_path: file_path, @@ -24,86 +27,99 @@ RSpec.describe Gitlab::Suggestions::SuggestionSet do let_it_be(:user) { create(:user) } let_it_be(:project) { create(:project, :repository) } + let_it_be(:forked_project) { fork_project(project, nil, repository: true) } - let_it_be(:merge_request) do + let_it_be(:merge_request_same_project) do create(:merge_request, source_project: project, target_project: project) end - let_it_be(:suggestion) { create(:suggestion)} - - let_it_be(:suggestion2) do - create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***") - end - - let_it_be(:suggestion3) do - create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***") + let_it_be(:merge_request_from_fork) do + create(:merge_request, source_project: forked_project, target_project: project) end - let_it_be(:unappliable_suggestion) { create(:suggestion, :unappliable) } + where(:merge_request) { [ref(:merge_request_same_project), ref(:merge_request_from_fork)] } + with_them do + let(:note) { create(:diff_note_on_merge_request, project: project, noteable: merge_request) } + let(:suggestion) { create(:suggestion, note: note) } - let(:suggestion_set) { described_class.new([suggestion]) } - - describe '#project' do - it 'returns the project associated with the suggestions' do - expected_project = suggestion.project + let(:suggestion2) do + create_suggestion('files/ruby/popen.rb', 13, "*** SUGGESTION 2 ***") + end - expect(suggestion_set.project).to be(expected_project) + let(:suggestion3) do + create_suggestion('files/ruby/regex.rb', 22, "*** SUGGESTION 3 ***") end - end - describe '#branch' do - it 'returns the branch associated with the suggestions' do - expected_branch = suggestion.branch + let(:unappliable_suggestion) { create(:suggestion, :unappliable) } + + let(:suggestion_set) { described_class.new([suggestion]) } - expect(suggestion_set.branch).to be(expected_branch) + describe '#source_project' do + it 'returns the source project associated with the suggestions' do + expect(suggestion_set.source_project).to be(merge_request.source_project) + end end - end - describe '#valid?' do - it 'returns true if no errors are found' do - expect(suggestion_set.valid?).to be(true) + describe '#target_project' do + it 'returns the target project associated with the suggestions' do + expect(suggestion_set.target_project).to be(project) + end end - it 'returns false if an error is found' do - suggestion_set = described_class.new([unappliable_suggestion]) + describe '#branch' do + it 'returns the branch associated with the suggestions' do + expected_branch = suggestion.branch - expect(suggestion_set.valid?).to be(false) + expect(suggestion_set.branch).to be(expected_branch) + end end - end - describe '#error_message' do - it 'returns an error message if an error is found' do - suggestion_set = described_class.new([unappliable_suggestion]) + describe '#valid?' do + it 'returns true if no errors are found' do + expect(suggestion_set.valid?).to be(true) + end - expect(suggestion_set.error_message).to be_a(String) + it 'returns false if an error is found' do + suggestion_set = described_class.new([unappliable_suggestion]) + + expect(suggestion_set.valid?).to be(false) + end end - it 'returns nil if no errors are found' do - expect(suggestion_set.error_message).to be(nil) + describe '#error_message' do + it 'returns an error message if an error is found' do + suggestion_set = described_class.new([unappliable_suggestion]) + + expect(suggestion_set.error_message).to be_a(String) + end + + it 'returns nil if no errors are found' do + expect(suggestion_set.error_message).to be(nil) + end end - end - describe '#actions' do - it 'returns an array of hashes with proper key/value pairs' do - first_action = suggestion_set.actions.first + describe '#actions' do + it 'returns an array of hashes with proper key/value pairs' do + first_action = suggestion_set.actions.first - file_suggestion = suggestion_set.send(:suggestions_per_file).first + file_suggestion = suggestion_set.send(:suggestions_per_file).first - expect(first_action[:action]).to be('update') - expect(first_action[:file_path]).to eq(file_suggestion.file_path) - expect(first_action[:content]).to eq(file_suggestion.new_content) + expect(first_action[:action]).to be('update') + expect(first_action[:file_path]).to eq(file_suggestion.file_path) + expect(first_action[:content]).to eq(file_suggestion.new_content) + end end - end - describe '#file_paths' do - it 'returns an array of unique file paths associated with the suggestions' do - suggestion_set = described_class.new([suggestion, suggestion2, suggestion3]) + describe '#file_paths' do + it 'returns an array of unique file paths associated with the suggestions' do + suggestion_set = described_class.new([suggestion, suggestion2, suggestion3]) - expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb) + expected_paths = %w(files/ruby/popen.rb files/ruby/regex.rb) - actual_paths = suggestion_set.file_paths + actual_paths = suggestion_set.file_paths - expect(actual_paths.sort).to eq(expected_paths) + expect(actual_paths.sort).to eq(expected_paths) + end end end end diff --git a/spec/lib/gitlab/tracking_spec.rb b/spec/lib/gitlab/tracking_spec.rb index cd83971aef9..cc973be8be9 100644 --- a/spec/lib/gitlab/tracking_spec.rb +++ b/spec/lib/gitlab/tracking_spec.rb @@ -149,4 +149,42 @@ RSpec.describe Gitlab::Tracking do described_class.event(nil, 'some_action') end end + + describe '.definition' do + let(:namespace) { create(:namespace) } + + let_it_be(:definition_action) { 'definition_action' } + let_it_be(:definition_category) { 'definition_category' } + let_it_be(:label_description) { 'definition label description' } + let_it_be(:test_definition) {{ 'category': definition_category, 'action': definition_action }} + + before do + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:event) + end + allow_next_instance_of(Gitlab::Tracking::Destinations::Snowplow) do |instance| + allow(instance).to receive(:event) + end + allow(YAML).to receive(:load_file).with(Rails.root.join('config/events/filename.yml')).and_return(test_definition) + end + + it 'dispatchs the data to .event' do + project = build_stubbed(:project) + user = build_stubbed(:user) + + expect(described_class).to receive(:event) do |category, action, args| + expect(category).to eq(definition_category) + expect(action).to eq(definition_action) + expect(args[:label]).to eq('label') + expect(args[:property]).to eq('...') + expect(args[:project]).to eq(project) + expect(args[:user]).to eq(user) + expect(args[:namespace]).to eq(namespace) + expect(args[:extra_key_1]).to eq('extra value 1') + end + + described_class.definition('filename', category: nil, action: nil, label: 'label', property: '...', + project: project, user: user, namespace: namespace, extra_key_1: 'extra value 1') + end + end end diff --git a/spec/lib/gitlab/url_sanitizer_spec.rb b/spec/lib/gitlab/url_sanitizer_spec.rb index aba4ca109a9..0ffbf5f81e7 100644 --- a/spec/lib/gitlab/url_sanitizer_spec.rb +++ b/spec/lib/gitlab/url_sanitizer_spec.rb @@ -139,6 +139,23 @@ RSpec.describe Gitlab::UrlSanitizer do it { is_expected.to eq(credentials) } end end + + context 'with mixed credentials' do + where(:url, :credentials, :result) do + 'http://a@example.com' | { password: 'd' } | { user: 'a', password: 'd' } + 'http://a:b@example.com' | { password: 'd' } | { user: 'a', password: 'd' } + 'http://:b@example.com' | { password: 'd' } | { user: nil, password: 'd' } + 'http://a@example.com' | { user: 'c' } | { user: 'c', password: nil } + 'http://a:b@example.com' | { user: 'c' } | { user: 'c', password: 'b' } + 'http://a:b@example.com' | { user: '' } | { user: 'a', password: 'b' } + end + + with_them do + subject { described_class.new(url, credentials: credentials).credentials } + + it { is_expected.to eq(result) } + end + end end describe '#user' do diff --git a/spec/lib/gitlab/usage/service_ping_report_spec.rb b/spec/lib/gitlab/usage/service_ping_report_spec.rb index 1f62ddd0bbb..b6119ab52ec 100644 --- a/spec/lib/gitlab/usage/service_ping_report_spec.rb +++ b/spec/lib/gitlab/usage/service_ping_report_spec.rb @@ -7,119 +7,86 @@ RSpec.describe Gitlab::Usage::ServicePingReport, :use_clean_rails_memory_store_c let(:usage_data) { { uuid: "1111", counts: { issue: 0 } } } - context 'when feature merge_service_ping_instrumented_metrics enabled' do - before do - stub_feature_flags(merge_service_ping_instrumented_metrics: true) + before do + allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance| + allow(instance).to receive(:missing_key_paths).and_return([]) + end - allow_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor) do |instance| - allow(instance).to receive(:missing_key_paths).and_return([]) - end + allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance| + allow(instance).to receive(:build).and_return({}) + end + end - allow_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload) do |instance| - allow(instance).to receive(:build).and_return({}) - end + context 'all_metrics_values' do + it 'generates the service ping when there are no missing values' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) end - context 'all_metrics_values' do - it 'generates the service ping when there are no missing values' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) + it 'generates the service ping with the missing values' do + expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance| + expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards']) end - it 'generates the service ping with the missing values' do - expect_next_instance_of(Gitlab::Usage::ServicePing::PayloadKeysProcessor, usage_data) do |instance| - expect(instance).to receive(:missing_instrumented_metrics_key_paths).and_return(['counts.boards']) - end - - expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance| - expect(instance).to receive(:build).and_return({ counts: { boards: 1 } }) - end - - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } }) + expect_next_instance_of(Gitlab::Usage::ServicePing::InstrumentedPayload, ['counts.boards'], :with_value) do |instance| + expect(instance).to receive(:build).and_return({ counts: { boards: 1 } }) end - end - - context 'for output: :metrics_queries' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - described_class.for(output: :metrics_queries) - end + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0, boards: 1 } }) end + end - context 'for output: :non_sql_metrics_values' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + context 'for output: :metrics_queries' do + it 'generates the service ping' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - described_class.for(output: :non_sql_metrics_values) - end + described_class.for(output: :metrics_queries) end + end - context 'when using cached' do - context 'for cached: true' do - let(:new_usage_data) { { uuid: "1112" } } - - it 'caches the values' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) + context 'for output: :non_sql_metrics_values' do + it 'generates the service ping' do + expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data) + described_class.for(output: :non_sql_metrics_values) + end + end - expect(Rails.cache.fetch('usage_data')).to eq(usage_data) - end + context 'when using cached' do + context 'for cached: true' do + let(:new_usage_data) { { uuid: "1112" } } - it 'writes to cache and returns fresh data' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) + it 'caches the values' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(usage_data) - expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) - end + expect(Rails.cache.fetch('usage_data')).to eq(usage_data) end - context 'when no caching' do - let(:new_usage_data) { { uuid: "1112" } } - - it 'returns fresh data' do - allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - - expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - - expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) - end - end - end - end + it 'writes to cache and returns fresh data' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - context 'when feature merge_service_ping_instrumented_metrics disabled' do - before do - stub_feature_flags(merge_service_ping_instrumented_metrics: false) - end + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) + expect(described_class.for(output: :all_metrics_values, cached: true)).to eq(new_usage_data) - context 'all_metrics_values' do - it 'generates the service ping when there are no missing values' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) - expect(described_class.for(output: :all_metrics_values)).to eq({ uuid: "1111", counts: { issue: 0 } }) + expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) end end - context 'for output: :metrics_queries' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + context 'when no caching' do + let(:new_usage_data) { { uuid: "1112" } } - described_class.for(output: :metrics_queries) - end - end + it 'returns fresh data' do + allow(Gitlab::UsageData).to receive(:data).and_return(usage_data, new_usage_data) - context 'for output: :non_sql_metrics_values' do - it 'generates the service ping' do - expect(Gitlab::UsageData).to receive(:data).and_return(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(usage_data) + expect(described_class.for(output: :all_metrics_values)).to eq(new_usage_data) - described_class.for(output: :non_sql_metrics_values) + expect(Rails.cache.fetch('usage_data')).to eq(new_usage_data) end end end diff --git a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb index 222198a58ac..6a37bfd106d 100644 --- a/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb +++ b/spec/lib/gitlab/usage_data_counters/ci_template_unique_counter_spec.rb @@ -5,30 +5,52 @@ require 'spec_helper' RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do describe '.track_unique_project_event' do using RSpec::Parameterized::TableSyntax + include SnowplowHelpers - let(:project_id) { 1 } + let(:project) { build(:project) } + let(:user) { build(:user) } shared_examples 'tracks template' do + let(:subject) { described_class.track_unique_project_event(project: project, template: template_path, config_source: config_source, user: user) } + it "has an event defined for template" do expect do - described_class.track_unique_project_event( - project_id: project_id, - template: template_path, - config_source: config_source - ) + subject end.not_to raise_error end it "tracks template" do expanded_template_name = described_class.expand_template_name(template_path) expected_template_event_name = described_class.ci_template_event_name(expanded_template_name, config_source) - expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project_id) + expect(Gitlab::UsageDataCounters::HLLRedisCounter).to(receive(:track_event)).with(expected_template_event_name, values: project.id) + + subject + end + + context 'Snowplow' do + it 'event is not tracked if FF is disabled' do + stub_feature_flags(route_hll_to_snowplow: false) + + subject - described_class.track_unique_project_event(project_id: project_id, template: template_path, config_source: config_source) + expect_no_snowplow_event + end + + it 'tracks event' do + subject + + expect_snowplow_event( + category: described_class.to_s, + action: 'ci_templates_unique', + namespace: project.namespace, + user: user, + project: project + ) + end end end - context 'with explicit includes' do + context 'with explicit includes', :snowplow do let(:config_source) { :repository_source } (described_class.ci_templates - ['Verify/Browser-Performance.latest.gitlab-ci.yml', 'Verify/Browser-Performance.gitlab-ci.yml']).each do |template| @@ -40,7 +62,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do end end - context 'with implicit includes' do + context 'with implicit includes', :snowplow do let(:config_source) { :auto_devops_source } [ @@ -60,7 +82,7 @@ RSpec.describe Gitlab::UsageDataCounters::CiTemplateUniqueCounter do it 'expands short template names' do expect do - described_class.track_unique_project_event(project_id: 1, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source) + described_class.track_unique_project_event(project: project, template: 'Dependency-Scanning.gitlab-ci.yml', config_source: :repository_source, user: user) end.not_to raise_error end end diff --git a/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb new file mode 100644 index 00000000000..d6eb67e5c35 --- /dev/null +++ b/spec/lib/gitlab/usage_data_counters/gitlab_cli_activity_unique_counter_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::UsageDataCounters::GitLabCliActivityUniqueCounter, :clean_gitlab_redis_shared_state do # rubocop:disable RSpec/FilePath + let(:user1) { build(:user, id: 1) } + let(:user2) { build(:user, id: 2) } + let(:time) { Time.current } + let(:action) { described_class::GITLAB_CLI_API_REQUEST_ACTION } + let(:user_agent) { { user_agent: 'GLab - GitLab CLI' } } + + context 'when tracking a gitlab cli request' do + it_behaves_like 'a request from an extension' + end +end diff --git a/spec/lib/gitlab/usage_data_queries_spec.rb b/spec/lib/gitlab/usage_data_queries_spec.rb index c3ac9d7db90..88322e1b971 100644 --- a/spec/lib/gitlab/usage_data_queries_spec.rb +++ b/spec/lib/gitlab/usage_data_queries_spec.rb @@ -34,14 +34,14 @@ RSpec.describe Gitlab::UsageDataQueries do describe '.redis_usage_data' do subject(:redis_usage_data) { described_class.redis_usage_data { 42 } } - it 'returns a class for redis_usage_data with a counter call' do + it 'returns a stringified class for redis_usage_data with a counter call' do expect(described_class.redis_usage_data(Gitlab::UsageDataCounters::WikiPageCounter)) - .to eq(redis_usage_data_counter: Gitlab::UsageDataCounters::WikiPageCounter) + .to eq(redis_usage_data_counter: "Gitlab::UsageDataCounters::WikiPageCounter") end - it 'returns a stringified block for redis_usage_data with a block' do + it 'returns a placeholder string for redis_usage_data with a block' do is_expected.to include(:redis_usage_data_block) - expect(redis_usage_data[:redis_usage_data_block]).to start_with('#